190 Commits

Author SHA1 Message Date
3a92adfb82 fix: typo 2025-08-16 23:47:27 +03:00
af4c627a04 Merge remote-tracking branch 'upstream/main' into beta 2025-08-16 23:30:45 +03:00
1e725e6d03 Merge remote-tracking branch 'upstream/main' into beta 2025-08-16 22:38:14 +03:00
Alejandro González
1454e3351e feat: consistently format all HTML, XML, JSON, CSS, JS, TS, SQL, TOML, YAML, and Markdown files as far as possible (#4193)
* feat: consistently format all HTML, XML, JSON, CSS, JS, TS, SQL, TOML, YAML, and Markdown files

* chore: deal with VS Code not being able to parse valid editorconfig syntax

Sometimes I'm surprised that computers even work.

* chore: get rid of IntelliJ IDE config files that should not be there

These were already added to the `.gitignore` a long time ago, and now
are being ignored by Prettier.

* fix: rename `tooling-config` `format` script to `fix` for it to run with Turbo
2025-08-16 17:40:31 +00:00
Alejandro González
6f59f4c110 fix: tag Labrinth and Daedalus Docker builds with the right metadata (#4147)
* fix: tag Labrinth and Daedalus Docker builds with the right metadata

* chore: make it clear that Labrinth license is AGPL-3.0-only
2025-08-16 15:47:11 +00:00
Alejandro González
8e0732bf01 chore: fix CI failure due to unexpected formatting (#4189) 2025-08-16 15:23:31 +00:00
Prospector
0cf3c1a88e update changelog 2025-08-15 13:05:58 -07:00
Prospector
8a3171d7c4 Fix search always resetting back to page 1 2025-08-15 13:04:51 -07:00
Prospector
e25d726da4 Revert "Implement a more robust IPC system between the launcher and client (#4159)"
This reverts commit 5ffcc48d75.
2025-08-15 12:54:38 -07:00
Prospector
11e99cb9d3 Fix notifications 2025-08-15 12:22:02 -07:00
Prospector
632b09ff3f fix version pages 2025-08-15 11:53:44 -07:00
Prospector
713571d50e update changelog 2025-08-15 11:29:55 -07:00
Cal H.
4ad6daa45c fix: DI nonsense (#4174)
* fix: DI nonsense

* fix: lint

* fix: client try di issue

* fix: injects outside of context

* fix: use .catch

* refactor: convert projects.vue to composition API.

* fix: moderation checklist notif pos change watcher

* fix: lint issues
2025-08-15 18:02:55 +00:00
François-Xavier Talbot
9b5f172170 Billing issues fixes (#4173)
* Multiple billing fixes

- Fix the open charge not having its amount + interval updated after
promoting the expiring subscription
- Fix proration rate being miscalculated (assumed the current
subscription interval was always monthly)
- Fix the open charge's interval and amount being updated on PATCH
/subscription/:id even if the payment intent was never confirmed

* Appease clippy

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

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

---------

Signed-off-by: François-Xavier Talbot <108630700+fetchfern@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-14 23:59:37 +00:00
Prospector
4f789a0ebc update phrasing on 0.10.4 changelog 2025-08-14 17:05:29 -07:00
Prospector
ee3ac37967 Update changelog 2025-08-14 16:15:59 -07:00
Cal H.
2aabcf36ee refactor: migrate to common eslint+prettier configs (#4168)
* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
2025-08-14 20:48:38 +00:00
Prospector
82697278dc include moderation pkg in frontend locales (#4169)
* include moderation pkg in frontend locales

* Fix moderation lib path

* remove prints

* feat: move moderation package into src folder

* fix: lint

---------

Co-authored-by: IMB11 <calum@modrinth.com>
Co-authored-by: Cal H. <hendersoncal117@gmail.com>
2025-08-14 15:56:51 +00:00
Alejandro González
0bc6502443 App surveys (#3605)
* feat: surveys

* make assigned and dismissed users fields optional

* fix: set required CSP sources for Tally forms to show up

* make only attempt on windows, temp bypass requirements

* fix: lint issues

* Add prompt for survey prior to popup

* lint

* hide ads when survey is open

---------

Signed-off-by: Cal H. <hendersoncal117@gmail.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Cal H. <hendersoncal117@gmail.com>
Co-authored-by: IMB11 <calum@modrinth.com>
2025-08-14 01:16:36 +00:00
Josiah Glosson
5ffcc48d75 Implement a more robust IPC system between the launcher and client (#4159)
* Implement a more robust IPC system between the launcher and client

* Clippy fix and cargo fmt

* Switch to cached JsonReader with LENIENT parsing to avoid race conditions

* Make RPC send messages in lines

* Try to bind to either IPv4 or IPv6 and communicate version

* Move message handling into a separate function to avoid too much code in a macro
2025-08-13 23:28:44 +00:00
Cal H.
b81e727204 feat: introduce dependency injection framework (#4091)
* feat: migrate frontend notifications to dependency injection based notificaton manager

* fix: lint

* fix: issues

* fix: compile error + notif binding issue

* refactor: move org context to new DI setup

* feat: migrate app notifications to DI + frontend styling

* fix: sidebar issues

* fix: dont use delete in computed

* fix: import and prop issue

* refactor: move handleError to main notification manager class

* fix: lint & build

* fix: merge issues

* fix: lint issues

* fix: lint issues

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Signed-off-by: Cal H. <hendersoncal117@gmail.com>
2025-08-13 20:48:52 +00:00
IMB11
9ea43a12fd fix: dom parser ssr issues (#4166)
* fix: dom parser ssr issues

* fix: type issue
2025-08-13 14:48:36 +00:00
IMB11
b279c43069 Author Validation Improvements (#4025)
* feat: set up typed nag (validators) system

* feat: start on frontend impl

* fix: shouldShow issues

* feat: continue work

* feat: re add submitting/re-submit nags

* feat: start work implementing validation checks using new nag system

* fix: links page + add more validations

* feat: tags validations

* fix: lint issues

* fix: lint

* fix: issues

* feat: start on i18nifying nags

* feat: impl intl

* fix: minecraft title clause update

* fix: locale issues

* refactor: inline i18n

* fix: summary char min

* fix: issues

* Rephrase a few core nags

* Modify character limit numbers

* Remove redundant sentanceEnders check to reduce false positive.

* Description nag rephrasing and tweaks

* Tweak links nags adding project type checking for source publication check, make description nag tonally consistent.

* fix: description nag

* bump source publication nag to warn until additional files can be checked.

* refactor link checking helper functions, prevent misuse of dsc links, prevent link shortener usage, check if source required licensed projects have additional files, bump this check back to required.

* Correct plugin project type checking

* fix: lint issues

* update links.ts

* feat: key + sort nags by type

* Tweak core and description nag titles, change image accessability nag logic.

* feat: update readme

* updates to tags checking and rest of the nag titles

* fix locale

* fix: formatjs

* fix tags warning, and link shorteners and misused discord warnings to link settings page, reword some warnings.

* correct vocabulary for resolutions tags warning and sort tags list in resolution tags nag

* lint fix

* fix method typo

* Add nag for summary formatting.

* Check for link shorteners in donation links

* add Gallery requirement nag for shaders and most resource packs

* update index.json

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
2025-08-13 08:45:13 +00:00
François-Xavier Talbot
9497ba70a4 Offers, redemption, preview subscriptions (#4121)
* Initial db migration/impl, guarded partner routes

* Add guard to /redeem

* Add `public` column to products prices, only expose public prices

* Query cache

* Add partner subscription type

* 5 days subscription interval, metadata

* Create server on redeem

* Query cache

* Fix race condition

* Unprovision Medal subscriptions

* Consider due expiring charge as unprovisionable

* Query cache

* Use a queue

* Promote to full subscription, fmt + clippy

* Patch expiring charge on promotion, comments

* Additional comments

* Add `tags` field to Archon /create request

* Address review comments

* Query cache

* Final fixes to edit_subscription

* Appease clippy

* fmt
2025-08-11 21:40:58 +00:00
coolbot
c02b809601 Update utils.ts (#4157) 2025-08-11 09:30:22 +00:00
1d000bb238 Merge commit 'df1499047ccc8f39d756d5beba60651237aca1c0' into beta 2025-08-10 19:54:24 +03:00
Alejandro González
df1499047c feat: set up Mailpit SMTP server as part of our Docker Compose file (#4151)
* feat(labrinth): support STMP servers with no auth credentials

* feat: set up Mailpit SMTP server as part of our Docker Compose services swarm

* chore(docker-compose): fix healthcheck for mail service

* feat(docker-compose): enable SpamAssassin integration through Postmark

Unlike spinning up yet another container, this requires no
configuration, and is good and simple enough for a funny little feature
developers may occassionally use with non-confidential messages.
2025-08-09 23:12:15 +00:00
Alejandro González
80eb297284 feat(labrinth): add config to run it with Compose, alongside services (#4153) 2025-08-09 21:04:31 +00:00
Alejandro González
58645b9ba9 Minor compose and editorconfig fixes (#4150)
* feat(docker-compose): give all containers a name

* fix(docker-compose): fix healthcheck for clickhouse container

For some reason, its CMD form always returned a healthcheck error, at
least in Podman.

* fix(editorconfig): address formatting regression for YAML files introduced in 8af65f58d9

* fix: frontend temp editorconfig change

* fix(editorconfig): add more extensions that use 2 spaces indentation

---------

Co-authored-by: Calum H. (IMB11) <hendersoncal117@gmail.com>
2025-08-09 20:00:10 +00:00
François-Xavier Talbot
544f63512a Use correct .git path in build.rs (#4145) 2025-08-09 18:59:22 +00:00
Alejandro González
3b8cd661bc feat(labrinth): database seed data fixtures for better installation and testing (#4132)
* feat(labrinth): database seed data fixtures for better installation and testing

* chore(labrinth): simplify and fixup seed data fixture

* docs(contributing/labrinth): enable all useful features for `sqlx-cli` install

* chore(docs/labrinth): fix typo

* chore(docs/labrinth): fix `cargo fmt` parameter typo

* chore: replace Labrinth -> labrinth
2025-08-09 14:51:04 +00:00
Josiah Glosson
8af65f58d9 Make Windows app installations per-user once again (#4136)
* Make Windows app installations per-user once again

* Add ShellExecuteWait credit

* Properly remove old shortcuts

* With *an* admin prompt

* Explicitly set installMode to currentUser
2025-08-09 14:50:37 +00:00
Alejandro González
ab79e84398 chore(docs/labrinth): remove unnecessary trailing spaces in lines (#4134)
* chore(docs/labrinth): remove unnecessary trailing spaces in lines

These don't serve an useful purpose, and overall the Markdown source
looks less tidy with them.

* chore: replace Labrinth -> labrinth
2025-08-09 14:36:20 +00:00
Josiah Glosson
cf190d86d5 Update Rust dependencies (#4139)
* Update Rust version

* Update async-compression 0.4.25 -> 0.4.27

* Update async-tungstenite 0.29.1 -> 0.30.0

* Update bytemuck 1.23.0 -> 1.23.1

* Update clap 4.5.40 -> 4.5.43

* Update deadpool-redis 0.21.1 -> 0.22.0 and redis 0.31.0 -> 0.32.4

* Update enumset 1.1.6 -> 1.1.7

* Update hyper-util 0.1.14 -> 0.1.16

* Update indexmap 2.9.0 -> 2.10.0

* Update indicatif 0.17.11 -> 0.18.0

* Update jemalloc_pprof 0.7.0 -> 0.8.1

* Update lettre 0.11.17 -> 0.11.18

* Update meilisearch-sdk 0.28.0 -> 0.29.1

* Update notify 8.0.0 -> 8.2.0 and notify-debouncer-mini 0.6.0 -> 0.7.0

* Update quick-xml 0.37.5 -> 0.38.1

* Fix theseus lint

* Update reqwest 0.12.20 -> 0.12.22

* Cargo fmt in theseus

* Update rgb 0.8.50 -> 0.8.52

* Update sentry 0.41.0 -> 0.42.0 and sentry-actix 0.41.0 -> 0.42.0

* Update serde_json 1.0.140 -> 1.0.142

* Update serde_with 3.13.0 -> 3.14.0

* Update spdx 0.10.8 -> 0.10.9

* Update sysinfo 0.35.2 -> 0.36.1

* Update tauri suite

* Fix build by updating mappings

* Update tokio 1.45.1 -> 1.47.1 and tokio-util 0.7.15 -> 0.7.16

* Update tracing-actix-web 0.7.18 -> 0.7.19

* Update zip 4.2.0 -> 4.3.0

* Misc Cargo.lock updates

* Update Dockerfiles
2025-08-08 22:50:44 +00:00
IMB11
ca0c16b1fe fix: use first project type as actual project type (#4128) 2025-08-08 21:45:58 +00:00
IMB11
17c9e4a721 revert: dont use local storage for filters (#4129) 2025-08-08 21:30:39 +00:00
IMB11
d7f1029b54 feat: add simple mode editor to the moderation checklist (#4127)
* fix: remove prettier, not needed.

* feat: simple/advanced mode for the checklist
2025-08-08 19:37:06 +00:00
Alejandro González
ad208536b0 feat(labrinth): allow editing loader fields in bulk in v3 project PATCH (#4140) 2025-08-08 14:10:42 +00:00
553db55c7b Merge commit 'd22c9e24f4ca63c8757af0e0d9640f5d0431e815' into beta 2025-08-07 12:08:32 +03:00
Alejandro González
d22c9e24f4 tweak(frontend): improve Nuxt build state generation logging and caching (#4133) 2025-08-06 22:05:33 +00:00
fishstiz
e31197f649 feat(app): pass selected version to incompatibility warning modal (#4115)
Co-authored-by: IMB11 <hendersoncal117@gmail.com>
2025-08-05 11:10:02 +00:00
Emma Alexia
0dee21814d Change "Billing" link on dashboard for admins (#3951)
* Change "Billing" link on dashboard for admins

Requires an archon change before merging

* change order

* steal changes from prospector's old PR

supersedes #3234

Co-authored-by: Prospector <prospectordev@gmail.com>

* lint?

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-08-04 20:13:33 +00:00
Josiah Glosson
0657e4466f Allow direct joining servers on old instances (#4094)
* Implement direct server joining for 1.6.2 through 1.19.4

* Implement direct server joining for versions before 1.6.2

* Ignore methods with a $ in them

* Run intl:extract

* Improve code of MinecraftTransformer

* Support showing last played time for profiles before 1.7

* Reorganize QuickPlayVersion a bit to prepare for singleplayer

* Only inject quick play checking in versions where it's needed

* Optimize agent some and fix error on NeoForge

* Remove some code for quickplay singleplayer support before 1.20, as we can't reasonably support that with an agent

* Invert the default hasServerQuickPlaySupport return value

* Remove Play Anyway button

* Fix "Server couldn't be contacted" on singleplayer worlds

* Fix "Jump back in" section not working
2025-08-04 19:29:20 +00:00
Josiah Glosson
13dbb4c57e Fix most packs showing as "Optimization" on the app homepage (#4119) 2025-08-04 19:21:37 +00:00
4c6290ead6 Merge commit '99493b9917b5f96c56a014404340b648a9dab2ef' into beta 2025-08-03 01:16:16 +03:00
Prospector
99493b9917 Updated changelog 2025-08-01 21:31:22 -04:00
IMB11
72a52eb7b1 fix: improve error message for rate limiting (#4101)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-08-01 21:27:25 +00:00
IMB11
b33e12c71d fix: startup settings not visible on hard page refresh/direct load (#4100)
* fix: startup settings not visible on hard page refresh/direct load

* refactor: const func => named
2025-08-01 21:22:22 +00:00
IMB11
82d86839c7 fix: approve status incorrect (#4104) 2025-08-01 20:24:40 +00:00
coolbot
3a20e15340 Coolbot/moderation updates aug1 (#4103)
* oop, all commas!

* Only show slug stuff when needed.

* Move status alerts to top of message, getting rid of separators.

* redist libs message altered, and now shows on plugins too

* Update versions.ts

remove unnecessary import

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>

* Tweak summary formatting msg

* Update license messages to use flink

* reorder link text to match the settings page

* add Description clarity button

---------

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
2025-08-01 20:21:28 +00:00
jade
1c89b84314 fix(moderation): Replace dead modpack link with a valid one in side-types message (#4095) 2025-07-31 17:50:33 +00:00
8d36c14554 Merge commit '6387fb21c6948190b2ed2cbda0568eff379179ab' into beta 2025-07-30 19:28:12 +03:00
IMB11
6387fb21c6 feat: Moderation Dashboard Overhaul (#4059)
* feat: Moderation Dashboard Overhaul

* fix: lint issues

* fix: issues

* fix: report layout

* fix: lint

* fix: impl quick replies

* fix: remove test qr

* feat: individual report page + use new backend

* feat: memoize filtering

* feat: apply optimizations to moderation queue

* fix: lint issues

* feat: impl quick reply functionality

* fix: top level await

* fix: dep issue

* fix: dep issue x2

* fix: dep issue

* feat: intl extract

* fix: dev-187

* fix: dev-186 & review project btn

* fix: dev-176

* remove redundant moderation button from user dropdown

* correct a msg and add admin to read filter

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
2025-07-29 21:19:25 +00:00
Alejandro González
c7d0839bfb fix(labrinth): retire Sendy for new email newsletter subscriptions (#4073)
* tweak(frontend): do not sign up for the newsletter by default

* fix(labrinth): retire Sendy for new email newsletter subscriptions
2025-07-29 09:51:50 +00:00
2b43e26a85 Merge commit '175b90be5a42e5bfd3289ffdfbf7b201404f82a8' into beta 2025-07-28 22:49:16 +03:00
Josiah Glosson
175b90be5a Legacy ping support (#4062)
* Detection of protocol versions before 18w47b

* Refactor old_protocol_versions into protocol_version

* Ping servers closer to how a client of an instance's version would ping a server

* Allow pinging legacy servers from a modern profile in the same way a modern client would

* Ping 1.4.2 through 1.5.2 like a Vanilla client in those versions would when in such an instance
2025-07-28 14:44:34 +00:00
coolbot
13103b4950 various moderation fixes and improvements (#4061)
* Typo correction

* show optimization button when present in additional categories

* add more formatted link shortcuts

* Add info text to env info stage

* Only show gallery relevancy button when relevant.

* add unsupported project type message to versions stage

* Fix misuse of slug message.

* Update unsupported_project.md

* lint fix
2025-07-28 12:56:47 +00:00
Alejandro González
8804478221 fix(frontend): hide subscription button in blog before sub status is determined (#4072) 2025-07-27 20:29:21 +00:00
Emma Alexia
b8982a6d17 Hopefully fix collection visibility once and for all (#4070)
* Hopefully fix collection visibility once and for all

Follow up to #3408 and #3864

* Use same unlisted approach for collections as is used for projects
2025-07-27 18:23:49 +00:00
Emma Alexia
ff88724d01 Allow modification of failed charges on admin billing page (#4045)
* Allow modification of failed charges on admin billing page

Allows cancelling a failed subscription and forcing another charge attempt

* use addNotification
2025-07-27 17:30:16 +00:00
Emma Alexia
7dffb352d5 Fix duplicate "Upload icon Select file" on collections (#4069)
* Fix duplicate "Upload icon Select file" on collections

![lol](https://i.imgur.com/NKfvfQD.png)

* fix lint
2025-07-27 17:27:02 +00:00
Emma Alexia
1df6e29aa1 Ensure server status info is always passed to "My servers" page (#4071)
This took an insanely long time to debug and figure out you would not believe
2025-07-27 17:10:52 +00:00
Emma Alexia
5deb4179ad Re-enable the Moderation tab for projects that are approved (#4067)
By request of the moderation team. This would allow easier access
if, e.g., the moderators tell the author of a metadata problem they
need to correct.
2025-07-27 17:07:39 +00:00
Alejandro González
358cf31c87 feat(labrinth): basic offset pagination for moderation reports and projects (#4063) 2025-07-26 12:32:35 +00:00
7cea4b21a8 ci: fix build 2025-07-26 00:16:09 +03:00
7846fd00aa ci: fix build 2025-07-25 07:51:11 +03:00
cebc195fe0 ci: update workflow script 2025-07-25 06:56:36 +03:00
Prospector
6db1d66591 else if 2025-07-24 10:38:23 -07:00
Prospector
8052fda840 Bump report limit to 1500 2025-07-24 10:37:01 -07:00
ae58f3844d add patch file 2025-07-24 18:04:14 +03:00
acd4b1696a fix: permissions for tauri build 2025-07-24 17:38:40 +03:00
5ea78b78c2 Merge pull request 'Implement Curseforge profile codes' (#10) from tomasalias/AstralRinth:release into beta
Reviewed-on: didirus/AstralRinth#10
2025-07-24 17:31:59 +03:00
f90998157d Merge branch 'beta' into release 2025-07-24 16:39:31 +03:00
634000cdb6 Merge commit '15892a88d345f7ff67e2e46e298560afb635ac23' into beta 2025-07-24 16:38:58 +03:00
tomasalias
5fd8c38c1c Implement Curseforge profile codes 2025-07-24 03:41:41 +02:00
IMB11
15892a88d3 fix: handle identified files properly in the checklist (#4004)
* fix: handle identified files from the backend

* fix: allFiles not being emitted after permissions flow completed

* fix: properly handle identified projects

* fix: jade issues

* fix: import

* fix: issue with perm gen msgs

* fix: incomplete error
2025-07-23 08:34:55 +00:00
Alejandro González
32793c50e1 feat(app): better external browser Modrinth login flow (#4033)
* fix(app-frontend): do not emit exceptions when no loaders are available

* refactor(app): simplify Microsoft login code without functional changes

* feat(app): external browser auth flow for Modrinth account login

* chore: address Clippy lint

* chore(app/oauth_utils): simplify `handle_reply` error handling according to review

* chore(app-lib): simplify `Url` usage out of MC auth module
2025-07-22 22:55:18 +00:00
Alejandro González
0e0ca1971a chore(ci): switch back to upstream cache-cargo-install-action (#4047) 2025-07-22 22:43:04 +00:00
Alejandro González
bb9af18eed perf(docker): cache image builds through cache mounts and GHA cache (#4020)
* perf(docker): cache image builds through cache mounts and GHA cache

* tweak(ci/docker): switch to inline registry cache
2025-07-22 22:31:56 +00:00
Alejandro González
d4516d3527 feat(app): configurable Modrinth endpoints through .env files (#4015) 2025-07-21 22:55:57 +00:00
Josiah Glosson
87de47fe5e Use rust-lld linker on MSVC Windows (#4042)
The latest version of MSVC fails when linking labrinth, making now a perfect opportunity to switch over to the rust-lld linker instead.
2025-07-21 22:35:05 +00:00
Emma Alexia
7d76fe1b6a Add more info about last attempts to admin billing dashboard (#4029) 2025-07-21 08:35:36 +00:00
46d30e491a ci: another fix 2025-07-21 02:20:12 +03:00
059c0618f1 ci: reconfigure output bundles 2025-07-21 02:00:52 +03:00
7ef60fcafe fix: incorrect authlib injector setup in special cases 2025-07-21 02:00:17 +03:00
ec17e79014 Merge pull request 'feature-elyby-account' (#9) from feature-elyby-account into beta
Reviewed-on: didirus/AstralRinth#9
2025-07-21 00:49:30 +03:00
e351d674f4 refactor: Improves some features in our utils.rs 2025-07-21 00:41:23 +03:00
f555fa916a (WIP) feat: ely.by account authentication 2025-07-20 08:10:04 +03:00
dbe38cb4e7 Merge commit 'ae25a15abd6e78be3d5dbf8f23aa1a5cdc53531e' into feature-elyby-account 2025-07-20 02:09:53 +03:00
2e40e26116 Merge commit 'a8caa1afc3115cc79da25d8129e749932c7dc2a5' into feature-elyby-account 2025-07-20 02:08:02 +03:00
Prospector
ae25a15abd Update changelog 2025-07-19 15:17:39 -07:00
Prospector
0f755b94ce Revert "Author Validation Improvements (#3970)" (#4024)
This reverts commit 44267619b6.
2025-07-19 22:04:47 +00:00
Emma Alexia
bcf46d440b Count failed payments as "open" charges (#4013)
This allows people to cancel failed payments, currently it fails with error "There is no open charge for this subscription"
2025-07-19 14:33:37 +00:00
Josiah Glosson
526561f2de Add --color to intl:extract verification (#4023) 2025-07-19 12:42:17 +00:00
Emma Alexia
a8caa1afc3 Clarify that Modrinth Servers are for Java Edition (#4021) 2025-07-18 18:37:06 +00:00
Emma Alexia
98e9a8473d Fix NeoForge instance importing from MultiMC/Prism (#4016)
Fixes DEV-170
2025-07-18 13:00:11 +00:00
coolbot
936395484e fix: status alerts and version buttons no longer cause a failed to generate error. (#4017)
* add empty message to actions with no message, fixing broken message generation.

* fix typo in 2.2 / description message.
2025-07-18 05:32:31 +00:00
Emma Alexia
0c3e23db96 Improve errors when email is already in use (#4014)
Fixes #1485

Also fixes an issue where email_verified was being set to true regardless of whether the oauth provider provides an email (thus indicating that a null email is verified)
2025-07-18 01:59:48 +00:00
Gwenaël DENIEL
013ba4d86d Update Browse.vue (#4000)
Updated functions refreshSearch and clearSearch to reset the currentPage.value to 1

Signed-off-by: Gwenaël DENIEL <monsieur.potatoes93@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-17 07:58:24 +00:00
coolbot
93813c448c Add buttons for tec team, as well as other requested actions (#4012)
* add tec rev related buttons, identity verification button, and fix edge case appearance of links stage.

* lint fix
2025-07-17 07:49:11 +00:00
coolbot
c20b869e62 fix text in license and links stages (#4010)
* fix text in license and links stages, change a license option to conditional

* remove unused project definition

* Switch markdown to use <br />

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-17 03:05:00 +00:00
Alejandro González
56c556821b refactor(app-frontend): followup to PR #3999 (#4008) 2025-07-17 00:07:18 +00:00
IMB11
44267619b6 Author Validation Improvements (#3970)
* feat: set up typed nag (validators) system

* feat: start on frontend impl

* fix: shouldShow issues

* feat: continue work

* feat: re add submitting/re-submit nags

* feat: start work implementing validation checks using new nag system

* fix: links page + add more validations

* feat: tags validations

* fix: lint issues

* fix: lint

* fix: issues

* feat: start on i18nifying nags

* feat: impl intl

* fix: minecraft title clause update

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-16 22:28:42 +00:00
10afd673db add download authlib injector support 2025-07-17 00:54:26 +03:00
Prospector
90043fe84d Remove tumblr from footer since it's no longer in use (#4001)
* Remove tumblr from footer since it's no longer in use

* remove import

* i18n extract

---------

Co-authored-by: IMB11 <hendersoncal117@gmail.com>
2025-07-16 20:44:56 +00:00
Prospector
a6a98ff63e remove import 2025-07-16 12:35:14 -07:00
AnotherPillow
911652133b fix: report body overflowing container (#3983)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-16 19:09:02 +00:00
IMB11
cee1b5f522 fix: use node instance url to fix staging (#4005)
* fix: use node instance url to fix staging

* fix: check if node instance exists first
2025-07-16 18:57:31 +00:00
coolbot
62f5a23fcb Moderation Checklist V1.5 (#3980)
* starting on new checklist implementation

Change default shouldShow behavior for stages.
add new messages and stages.
Change some existing stage logic.
Add placeholder var for the rules.

Co-Authored-By: @coolbot100s

* misc fixes + corrections

* Add clickable link previews to links stage

* Correct mislabeled title message and add new title messages

* Change message formatting, use rules variable, correct wip desc and title 1.8 messages, add tags buttons

* More applications of rules placeholder

* Add new status alerts stage

* change order of statusAlerts

* Update title related messages, add navigation based vars

* Overhaul Links stage and add new messages.

* Set message weights, add some disables

* message.mds now obey lint >:(

* fixed links text message formatting and changed an icon

* Combine title and slug stages

* Add more info to some stages and properly case stage ids

* tweak summary text formatting

* Improved tags stage info and more navigation placeholders

* redo reupload stage, more navigation placeholders, licensing stage improvements, versions stage improvements, status alerts stage improvements

* Allow modpack permissions stage to appear again by adding a dummy button.

* Update modpack permissions guidance

* fix: blog path issues

* fix: lint issues

* fix license stage text formatting

* Improve license stage

* feat: move links into one md file to be cleaner

* Update packages/moderation/data/stages/links.ts

Signed-off-by: IMB11 <hendersoncal117@gmail.com>

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: IMB11 <calum@modrinth.com>
2025-07-16 18:48:26 +00:00
5a10292add feat: add support for multiple account types in database 2025-07-16 20:33:58 +03:00
3f606a08aa Merge commit 'eb595cdc3e4a6953cbde00c0e119e476ef767a52' into beta 2025-07-16 14:34:07 +03:00
2d5d747202 update readme markdown files, added RUS language 2025-07-16 13:49:50 +03:00
Silcean
eb595cdc3e Feature/detect skin variant on fileinput (#3999)
* chaged detection algorithm, and added skin variant deteciton on fileinput

* Update skins.ts

removed leftover logs

* removed pnpm lock changes. Simplyfied the transparency check in skin variant detection

* fully reverted lock.yaml. my bad.

---------

Co-authored-by: Bronchiopator <70262842+Bronchiopator@users.noreply.github.com>
2025-07-16 10:43:30 +00:00
7516ff9e47 Merge pull request 'feature-elyby-skins' (#8) from feature-elyby-skins into beta
Reviewed-on: didirus/AstralRinth#8
2025-07-16 13:13:03 +03:00
Josiah Glosson
572cd065ed Allow joining offline servers from the Worlds tab (#3998)
* Allow joining offline servers from the Worlds tab

* Run intl:extract

* Fix lint
2025-07-15 23:58:04 +00:00
df9bbe3ba0 chore: add patch file to patches directory 2025-07-16 02:31:59 +03:00
362fd7f32a feat: Implement Ely By skin system 2025-07-16 02:27:48 +03:00
Prospector
76dc8a0897 Update DDoS protection on Modrinth Servers page 2025-07-15 13:51:35 -07:00
Prospector
4723de6269 Update MRS marketing and add copyright policy to footer 2025-07-15 12:36:29 -07:00
Prospector
e15fa35bad Update changelog 2025-07-15 08:15:26 -07:00
7716a0c524 Merge pull request 'beta' (#7) from beta into release
Reviewed-on: didirus/AstralRinth#7
2025-07-15 00:47:12 +03:00
IMB11
2cc6bc8ce4 fix: unidentified files not showing in final checklist message (#3992)
* fix: unidentified files not showing in final message

* fix: remove condition
2025-07-14 15:23:15 +00:00
Nitrrine
5d19d31b2c fix(web): prevent gallery item description from overflowing (#3990)
* fix(web): prevent gallery item description from overflowing

* break overflowing text instead of hiding it

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

* fix: fix

---------

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>
2025-07-13 23:36:43 +00:00
IMB11
c1b95ede07 fix: checklist conditional message issues + MD formatting (#3989) 2025-07-13 20:23:06 +00:00
IMB11
058185c7fd Moderation Checklist Fixes (#3986)
* fix: DEV-164

* fix: dev-163

* feat: DEV-162
2025-07-13 18:08:55 +00:00
IMB11
6fb125cf0f fix: keybind issue + view moderation page on final step (#3977)
* fix: keybind issue + view moderation page on final step

* fix: go to moderation page on generate message thing
2025-07-12 21:48:53 +00:00
Alejandro González
a945e9b005 tweak(labrinth): drop last remaining dependency on system OpenSSL (#3982)
We standarized on using `rustls` as a TLS implementation across the
monorepo, which is written in Rust and has better ergonomics,
integration with the Rust ecosystem, and consistent behavior among
platforms. However, the Labrinth Clickhouse client was the last
remaining exception to this, using the native, OS-provided TLS
implementation, which on Linux is OpenSSL and requires developers and
Docker images to install OpenSSL development packages to build Labrinth,
in addition to introducing an additional runtime dependency to Labrinth.

Let's make the process of building Labrinth slightly simpler by
switching such client to `rustls` as well, which results in finally
using the same TLS implementation for everything, a simplified build and
distribution process, less transitive dependencies, and potentially
smaller binaries (since `rustls` was already being pulled in for, e.g.,
the SMTP client).
2025-07-12 13:39:41 +00:00
Josiah Glosson
b943638afb Verify that intl:extract has been run (#3979) 2025-07-11 21:48:07 +00:00
IMB11
207dc0e2bb fix: keybind for collapse (#3971) 2025-07-11 16:41:14 +00:00
IMB11
359fbd4738 feat: moderation improvements (#3881)
* feat: rough draft of tool

* fix: example doc

* feat: multiselect chips

* feat: conditional actions+messaages + utils for handling conditions

* feat: migrate checklist v1 to new format.

* fix: lint issues

* fix: severity util

* feat: README.md

* feat: start implementing new moderation checklist

* feat: message assembly + fix imports

* fix: lint issues

* feat: add input suggestions

* feat: utility cleanup

* fix: icon

* chore: remove debug logging

* chore: remove debug button

* feat: modpack permissions flow into it's own component

* feat: icons + use id in stage selection button

* Support md/plain text in stages.

* fix: checklist not persisting/showing on subpages

* feat: message gen + appr/with/deny buttons

* feat: better notification placement + queue navigation

* fix: default props for futureProjects

* fix: modpack perms message

* fix: issue with future projects props

* fix: tab index + z index fixes

* feat: keybinds

* fix: file approval types

* fix: generate message for non-modpack projects

* feat: add generate message to stages dropdown

* fix: variables not expanding

* feat: requests

* fix: empty message approval

* fix: issues from sync

* chore: add comment for old moderation checklist impl

* fix: git artifacts

* fix: update visibility logic for stages and actions

* fix: cleanup logic for should show

* fix: markdown editor accidental edit
2025-07-11 16:09:04 +00:00
adf831dab9 Merge commit 'efeac22d14fb6e782869d56d5d65685667721e4a' into feature-elyby-skins 2025-07-11 04:41:14 +03:00
efeac22d14 Merge pull request 'feature-improve-updater' (#6) from feature-improve-updater into beta
Reviewed-on: didirus/AstralRinth#6
2025-07-11 04:10:01 +03:00
591d98a9eb fix: crlf hash? 2025-07-11 03:56:11 +03:00
77472d9a09 fix: crlf hash? 2025-07-11 03:46:33 +03:00
789d666515 refactor: windows auto updater only works with signed app 2025-07-11 03:26:04 +03:00
d917bff6ef feat: add ability to auto exec downloaded installer on windows; minor changes 2025-07-11 03:04:37 +03:00
4e69cd8bde feat: add auto application restart after migration successful fix attempt 2025-07-11 02:38:23 +03:00
b71e4cc6f9 refactor: update checker moved to App.vue, added new animated icons 2025-07-11 02:29:05 +03:00
a56ab6adb9 refactor: move updates to settings 2025-07-11 01:34:31 +03:00
f1b67c9584 refactor: improve ErrorModal.vue 2025-07-10 23:12:47 +03:00
3d32640b83 refactor: comments 2025-07-10 21:32:44 +03:00
6dfb599e14 Merge pull request 'feature-another-migration-fix' (#5) from feature-another-migration-fix into beta
Reviewed-on: didirus/AstralRinth#5
2025-07-10 21:18:40 +03:00
332a543f66 fix: added ability for regenerate checksums with issued mr migrations. 2025-07-10 21:09:06 +03:00
1ef96c447e ci: patch validating git config on windows runner 2025-07-10 16:30:04 +03:00
1ec92b5f97 ci: add steps with LF & CRLF checks 2025-07-10 15:59:17 +03:00
f0a4532051 ci: update astralrinth-build.yml 2025-07-10 15:53:26 +03:00
Prospector
f7700acce4 Updated changelog 2025-07-09 22:12:32 -07:00
IMB11
87a3e2d022 fix: white cape (#3959) 2025-07-09 23:31:00 +00:00
Prospector
5d17663040 Don't unnecessarily markdown-ify links pasted into markdown editor (#3958) 2025-07-09 23:11:17 +00:00
ToBinio
cff3c72f94 feat(theseus): add snapPoints for memory sliders (#1275)
* feat: add snapPoints for memory sliders

* fix lint

* Reapply changes

* Hide snap point display when disabled

* fix unused imports

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-09 22:59:59 +00:00
Erb3
fadf475f06 docs(frontend): add security.txt (#2252)
* feat: add security.txt

Security.txt is a well-known (pun intended) file among security researchers, so they don't have to go scavenging for your security information. More information is available on [securitytxt.org](https://securitytxt.org/).

I've set the following values:

- The email to contact with issues, `jai@modrinth.com`. This is the email stated in the security policy. If you wish to not include it here due to spam, you should also not have it as a `mailto` link in the security policy.
- Expiry is set to 2030. By this time Modrinth has become the biggest Minecraft mod distributor, and having expanded into other games. By this time they should also have updated this file.
- English is the preferred language
- The file is located at modrinth.com/.well-known/security.txt
- The security policy is at https://modrinth.com/legal/security

The following values have been left unset:

- PGP key, not sure where this would be located, if there is one
- Acknowledgments. Modrinth does currently not have a site for thanks
- Hiring, as it wants security-related positions
- CSAF, a Common Security Advisory Framework ?

* fix(docs): reduce security.txt expiry

This addresses a concern where the security.txt has a long expiration date. Someone could treat this as "use this until then", which we don't want since it's a long time. The specification recommends no longer than one year, as it is to mark as stale.

From the RFC:

> The "Expires" field indicates the date and time after which the data contained in the "security.txt" file is considered stale and should not be used (as per Section 5.3). The value of this field is formatted according to the Internet profiles of [ISO.8601-1] and [ISO.8601-2] as defined in [RFC3339]. It is RECOMMENDED that the value of this field be less than a year into the future to avoid staleness.

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

* fix(frontend): extend security.txt expiry

It takes so long to merge the PR :(

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

* docs(frontend) careers link in security.txt

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

---------

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>
Co-authored-by: Erb3 <49862976+Erb3@users.noreply.github.com>
2025-07-09 15:51:46 -07:00
tippfehlr
7228499737 fix(theseus-gui): fix sort/group by game version (#1250)
* fix(theseus-gui): fix sort/group by game version

In the Library, game version 1.8.9 is sorted/grouped after 1.20 because
the default sorting sorts 2 < 8
therefore localeCompare(with numeric=true) is needed, it detects 8 < 20
and puts the versions in the correct order.

* lint

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-09 22:30:11 +00:00
Prospector
bca467a634 Add coventry europe region support (#3956)
* Add coventry europe region support, rename germany EU location to central europe

* extract messages

* extract messages again
2025-07-09 22:27:59 +00:00
14f6450cf4 Merge commit '14bf06e4bd7a70a2c6483e6bbb9712cb9ff84486' into feature-elyby-skins 2025-07-10 01:07:43 +03:00
14bf06e4bd Merge commit 'cb72d2ac80910cf01c9d2025d04d772fb8397abd' into beta 2025-07-10 01:07:09 +03:00
IMB11
cb72d2ac80 Skins improvements/fixes (#3943)
* feat: only initialize batch renderer if needed & head storage

* feat: support webp storage of skin renders if supported (falls back to png if not)

* fix: performance improvements with cache loading+saving

* fix: mirrored skins + remove cape model for embedded cape

* feat: antialiasing

* fix: leg jumping & store fbx's for reference

* fix: lint issues

* fix: lint issues

* feat: tweaks to radial spotlight

* fix: app nav btn colors
2025-07-09 21:41:36 +00:00
Nitrrine
3c79607d1f feat(app): increase logs card height (#3953) 2025-07-09 21:39:51 +00:00
97bd18c7b3 Merge commit '8af0288274bedbd78db03530b10aca0c5a3f13f1' into feature-elyby-skins 2025-07-10 00:01:12 +03:00
8af0288274 Merge commit '36ad1f16e46333cd85d4719d8ecfcfd745167075' into beta 2025-07-09 23:57:16 +03:00
167072de0c Merge pull request 'feat: added fake host for bypass minecraft account type (MSA) check on 1.16.4/5.' (#4) from feature-mp-button-fix into beta
Reviewed-on: didirus/AstralRinth#4
2025-07-09 23:49:07 +03:00
2df37be9a7 feat: added fake host for bypass minecraft account type (MSA) check on 1.16.4/5. 2025-07-09 23:45:21 +03:00
34d85a03b2 Merge commit '17cf5e31321ef9c3a4f95489eb18d33818fb2090' into feature-elyby-skins 2025-07-09 22:58:27 +03:00
17cf5e3132 Merge pull request 'feature-fix-db' (#3) from feature-fix-db into beta
Reviewed-on: didirus/AstralRinth#3
2025-07-09 00:56:09 +03:00
Alejandro González
36ad1f16e4 ci(theseus): assorted tweaks and fixes (#3949)
* ci(theseus-build): ensure only relevant bundle artifacts are uploaded

Tauri leaves behind quite a bit of intermediate garbage in these target
folders, even when building with no build cache.

* ci(theseus-release): fix typo in RPM package URL generation

* ci(theseus-build): generate shorter and more user-friendly commit build versions
2025-07-08 21:02:17 +00:00
Prospector
5d4f334505 Update changelog 2025-07-08 13:57:06 -07:00
Prospector
1fdb5ba748 Add authors to blog posts and shorten some summaries (#3940) 2025-07-08 20:48:27 +00:00
c5e67a5c6f fix: typo 2025-07-08 23:43:50 +03:00
e2e21c1496 fix: another try to fix x86 windows arch 2025-07-08 23:40:55 +03:00
6da942ccbb fix: Ignore x86 windows arch 2025-07-08 23:32:33 +03:00
IMB11
26df6f51ef fix: composable used outside ... issue + disable cache (#3947) 2025-07-08 20:09:36 +00:00
Alejandro González
6caf794ae1 dist(docker): add curl package to Labrinth image, some other minor tweaks (#3915)
* dist(docker): add `.dockerignore` as symlink to `.gitignore`

This ensures that no files outside of version control are transferred to
the Docker build context for Labrinth and Daedalus images, which
significantly improves build speed (if a `target` directory is already
present) and build reproducibility.

* chore(dist/docker): simplify out unneeeded statements, move `SQLX_OFFLINE` env var setting to build command itself

The latter approach ensures that developers building the image locally
don't forget to set `SQLX_OFFLINE`, too.

* dist(docker): add `curl` package to Labrinth image
2025-07-08 19:22:15 +00:00
Alejandro González
2692953e31 fix(app): make Party Alex bonus default skin have slim arms (#3945)
This skin was incorrectly declared as having wide arms. Resolves #3941.
2025-07-08 19:03:30 +00:00
Prospector
242fd713ab Update changelog 2025-07-08 11:06:50 -07:00
IMB11
7a12c4d5e2 feat: reimplement error handling improvements w/o polling (#3942)
* Reapply "fix: error handling improvements (#3797)"

This reverts commit e0cde2d6ff.

* fix: polling issues

* fix: circuit breaker logic for spam

* fix: remove polling & ping test node instead

* fix: remove broken url from debugging

* fix: show error information display if node access fails in fs module
2025-07-08 17:40:44 +00:00
0ab4dec62d Merge pull request 'v0.10.302' (#2) from feature-clean into beta
Reviewed-on: didirus/AstralRinth#2
2025-07-08 18:00:08 +03:00
Prospector
f256ef43c0 Add x-archon-request header 2025-07-07 22:16:26 -07:00
3ecb20afd6 chore: store db fix patch file in all patches 2025-07-08 05:15:01 +03:00
1e10f24efe fix: typo 2025-07-08 05:06:39 +03:00
006fd7c7f5 fix: another sqlx migrations fix 2025-07-08 04:59:33 +03:00
1e8e001eb8 fix: Impl. fixes for all known migration issues from modrinth authors 2025-07-08 04:25:37 +03:00
585935c799 fix: Impl. fix for migration 20240711194701 2025-07-08 03:42:03 +03:00
Prospector
e0cde2d6ff Revert "fix: error handling improvements (#3797)"
This reverts commit 706976439d.
2025-07-07 17:37:43 -07:00
a64c3360d2 refactor: remove unnecessary code lines 2025-07-08 03:08:27 +03:00
Prospector
e4e77dc0d2 Revert "temp: do not retry MRS requests"
This reverts commit 8ba6467f21.
2025-07-07 17:07:27 -07:00
Prospector
8ba6467f21 temp: do not retry MRS requests 2025-07-07 16:49:17 -07:00
a2b2711204 refactor: Improve update.js and RunningAppBar.vue.
Bump to v0.10.302
2025-07-08 01:12:16 +03:00
Josiah Glosson
088cb54317 Fix failure when "Test"ing a Java installation (#3935)
* Fix failure when "Test"ing a Java installation

* Fix lint
2025-07-07 19:11:36 +00:00
ab57926e44 ref: Remove unused workflow steps 2025-07-07 19:20:40 +03:00
35cd79727a Merge commit 'c47bcf665d0686db29732629b36113d3b25915af' into feature-clean 2025-07-07 19:04:00 +03:00
Josiah Glosson
c47bcf665d Fix MinecraftLaunch failing in the case of a package-private main class on Java 8 (#3932)
I don't know of any mod loaders where this is the case, but better be safe than sorry
2025-07-07 15:42:38 +00:00
1098 changed files with 130909 additions and 108818 deletions

View File

@@ -2,5 +2,8 @@
[target.'cfg(windows)'] [target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"] rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
[build] [build]
rustflags = ["--cfg", "tokio_unstable"] rustflags = ["--cfg", "tokio_unstable"]

1
.dockerignore Symbolic link
View File

@@ -0,0 +1 @@
.gitignore

View File

@@ -3,16 +3,23 @@ root = true
[*] [*]
charset = utf-8 charset = utf-8
indent_style = space indent_style = tab
indent_size = 2
end_of_line = lf end_of_line = lf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
max_line_length = 100 max_line_length = 100
[*.md] [*.md]
indent_size = 2
max_line_length = off max_line_length = off
trim_trailing_whitespace = false
[*.{rs,java,kts}] [*.toml]
indent_size = 4 indent_size = 2
[*.json]
indent_size = 2
# YAML requires space indentation by spec
[*.{yml,yaml}]
indent_size = 2
indent_style = space

View File

@@ -16,6 +16,7 @@ on:
- 'packages/assets/**' - 'packages/assets/**'
- 'packages/ui/**' - 'packages/ui/**'
- 'packages/utils/**' - 'packages/utils/**'
workflow_dispatch:
jobs: jobs:
build: build:
@@ -23,10 +24,11 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
platform: [macos-latest, windows-latest, ubuntu-latest] # platform: [macos-latest, windows-latest, ubuntu-latest]
platform: [windows-latest, ubuntu-latest]
include: include:
- platform: macos-latest # - platform: macos-latest
artifact-target-name: universal-apple-darwin # artifact-target-name: universal-apple-darwin
- platform: windows-latest - platform: windows-latest
artifact-target-name: x86_64-pc-windows-msvc artifact-target-name: x86_64-pc-windows-msvc
- platform: ubuntu-latest - platform: ubuntu-latest
@@ -40,6 +42,35 @@ jobs:
with: with:
fetch-depth: 2 fetch-depth: 2
- name: 🔍 Validate Git config does not introduce CRLF
shell: bash
run: |
echo "🔍 Checking Git config for CRLF settings..."
autocrlf=$(git config --get core.autocrlf || echo "unset")
eol_setting=$(git config --get core.eol || echo "unset")
echo "core.autocrlf = $autocrlf"
echo "core.eol = $eol_setting"
if [ "$autocrlf" = "true" ]; then
echo "⚠️ WARNING: core.autocrlf is set to 'true'. Consider setting it to 'input' or 'false'."
fi
if [ "$eol_setting" = "crlf" ]; then
echo "⚠️ WARNING: core.eol is set to 'crlf'. Consider unsetting it or setting to 'lf'."
fi
- name: 🔍 Check migration files line endings (LF only)
shell: bash
run: |
echo "🔍 Scanning migration SQL files for CR characters (\\r)..."
if grep -Iq $'\r' packages/app-lib/migrations/*.sql; then
echo "❌ ERROR: Some migration files contain CR (\\r) characters — expected only LF line endings."
exit 1
fi
echo "✅ All migration files use LF line endings"
- name: 🧰 Setup Rust toolchain - name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
@@ -65,6 +96,11 @@ jobs:
librsvg2-dev \ librsvg2-dev \
xdg-utils \ xdg-utils \
openjdk-11-jdk openjdk-11-jdk
- name: ⚙️ Set application environment
shell: bash
run: |
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache - name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8 uses: rharkor/caching-for-turbo@v1.8
@@ -73,7 +109,7 @@ jobs:
run: pnpm install run: pnpm install
- name: ✍️ Set up Windows code signing (jsign) - name: ✍️ Set up Windows code signing (jsign)
if: matrix.platform == 'windows' && env.SIGN_WINDOWS_BINARIES == 'true' if: matrix.platform == 'windows-latest' && env.SIGN_WINDOWS_BINARIES == 'true'
shell: bash shell: bash
run: | run: |
choco install jsign --ignore-dependencies choco install jsign --ignore-dependencies
@@ -84,12 +120,12 @@ jobs:
rm -rf target/release/bundle rm -rf target/release/bundle
rm -rf target/*/release/bundle || true rm -rf target/*/release/bundle || true
- name: 🔨 Build macOS app # - name: 🔨 Build macOS app
if: matrix.platform == 'macos-latest' # if: matrix.platform == 'macos-latest'
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json # run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
env: # env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} # TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} # TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Linux app - name: 🔨 Build Linux app
if: matrix.platform == 'ubuntu-latest' if: matrix.platform == 'ubuntu-latest'

8
.idea/.gitignore generated vendored
View File

@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

20
.idea/code.iml generated
View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/apps/daedalus_client/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/daedalus/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/app-playground/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/app/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

7
.idea/discord.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

View File

@@ -1,26 +0,0 @@
<component name="libraryTable">
<library name="KotlinJavaRuntime" type="repository">
<properties maven-id="org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0" />
<CLASSES>
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib/1.8.0/kotlin-stdlib-1.8.0.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-common/1.8.0/kotlin-stdlib-common-1.8.0.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/annotations/13.0/annotations-13.0.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0.jar!/" />
</CLASSES>
<JAVADOC>
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0-javadoc.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib/1.8.0/kotlin-stdlib-1.8.0-javadoc.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-common/1.8.0/kotlin-stdlib-common-1.8.0-javadoc.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/annotations/13.0/annotations-13.0-javadoc.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0-javadoc.jar!/" />
</JAVADOC>
<SOURCES>
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0-sources.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib/1.8.0/kotlin-stdlib-1.8.0-sources.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-common/1.8.0/kotlin-stdlib-common-1.8.0-sources.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/annotations/13.0/annotations-13.0-sources.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0-sources.jar!/" />
</SOURCES>
</library>
</component>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
</modules>
</component>
</project>

12
.idea/vcs.xml generated
View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
Cargo.lock
pnpm-lock.yaml
.github/**/*.png

20
.vscode/settings.json vendored
View File

@@ -1,9 +1,15 @@
{ {
"prettier.endOfLine": "lf", "prettier.endOfLine": "lf",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": true, "editor.detectIndentation": false,
"editor.codeActionsOnSave": { "editor.insertSpaces": false,
"source.fixAll.eslint": "explicit" "files.eol": "\n",
} "files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "always"
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
} }

View File

@@ -2,7 +2,7 @@
All packages in this repository are licensed under their respective licenses. For more information, refer to the LICENSE file in each package. All packages in this repository are licensed under their respective licenses. For more information, refer to the LICENSE file in each package.
For detailed information, consult each package's COPYING.md file, if available. For detailed information, consult each package's COPYING.md, LICENSE.txt, or LICENSE file, if available.
## Modrinth Branding ## Modrinth Branding

1545
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ members = [
"apps/app", "apps/app",
"apps/app-playground", "apps/app-playground",
"apps/daedalus_client", "apps/daedalus_client",
"apps/labrinth", "apps/labrinth",
"packages/app-lib", "packages/app-lib",
"packages/ariadne", "packages/ariadne",
"packages/daedalus", "packages/daedalus",
] ]
[workspace.package] [workspace.package]
@@ -25,31 +25,29 @@ actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" } ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17" async_zip = "0.0.17"
async-compression = { version = "0.4.25", default-features = false } async-compression = { version = "0.4.27", default-features = false }
async-recursion = "1.1.1" async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [ async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls", "runtime-tokio-hyper-rustls",
] } ] }
async-trait = "0.1.88" async-trait = "0.1.88"
async-tungstenite = { version = "0.29.1", default-features = false, features = [ async-tungstenite = { version = "0.30.0", default-features = false, features = ["futures-03-sink"] }
"futures-03-sink",
] }
async-walkdir = "2.1.0" async-walkdir = "2.1.0"
base64 = "0.22.1" base64 = "0.22.1"
bitflags = "2.9.1" bitflags = "2.9.1"
bytemuck = "1.23.0" bytemuck = "1.23.1"
bytes = "1.10.1" bytes = "1.10.1"
censor = "0.3.0" censor = "0.3.0"
chardetng = "0.1.17" chardetng = "0.1.17"
chrono = "0.4.41" chrono = "0.4.41"
clap = "4.5.40" clap = "4.5.43"
clickhouse = "0.13.3" clickhouse = "0.13.3"
color-thief = "0.2.2" color-thief = "0.2.2"
console-subscriber = "0.4.1" console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" } daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0" dashmap = "6.1.0"
data-url = "0.3.1" data-url = "0.3.1"
deadpool-redis = "0.21.1" deadpool-redis = "0.22.0"
dirs = "6.0.0" dirs = "6.0.0"
discord-rich-presence = "0.2.5" discord-rich-presence = "0.2.5"
dotenv-build = "0.1.1" dotenv-build = "0.1.1"
@@ -57,7 +55,7 @@ dotenvy = "0.15.7"
dunce = "1.0.5" dunce = "1.0.5"
either = "1.15.0" either = "1.15.0"
encoding_rs = "0.8.35" encoding_rs = "0.8.35"
enumset = "1.1.6" enumset = "1.1.7"
flate2 = "1.1.2" flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false } fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false } futures = { version = "0.3.31", default-features = false }
@@ -67,100 +65,104 @@ heck = "0.5.0"
hex = "0.4.3" hex = "0.4.3"
hickory-resolver = "0.25.2" hickory-resolver = "0.25.2"
hmac = "0.12.1" hmac = "0.12.1"
hyper-tls = "0.6.0" hyper = "1.6.0"
hyper-util = "0.1.14" hyper-rustls = { version = "0.27.7", default-features = false, features = [
"http1",
"native-tokio",
"ring",
"tls12",
] }
hyper-util = "0.1.16"
iana-time-zone = "0.1.63" iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] } image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0" indexmap = "2.10.0"
indicatif = "0.17.11" indicatif = "0.18.0"
itertools = "0.14.0" itertools = "0.14.0"
jemalloc_pprof = "0.7.0" jemalloc_pprof = "0.8.1"
json-patch = { version = "4.0.0", default-features = false } json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.17", default-features = false, features = [ lettre = { version = "0.11.18", default-features = false, features = [
"builder", "builder",
"hostname", "hostname",
"pool", "pool",
"ring", "ring",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"smtp-transport", "smtp-transport",
] } ] }
maxminddb = "0.26.0" maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.28.0", default-features = false } meilisearch-sdk = { version = "0.29.1", default-features = false }
murmur2 = "0.1.0" murmur2 = "0.1.0"
native-dialog = "0.9.0" native-dialog = "0.9.0"
notify = { version = "8.0.0", default-features = false } notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false } notify-debouncer-mini = { version = "0.7.0", default-features = false }
p256 = "0.13.2" p256 = "0.13.2"
paste = "1.0.15" paste = "1.0.15"
phf = { version = "0.12.1", features = ["macros"] }
png = "0.17.16" png = "0.17.16"
prometheus = "0.14.0" prometheus = "0.14.0"
quartz_nbt = "0.2.9" quartz_nbt = "0.2.9"
quick-xml = "0.37.5" quick-xml = "0.38.1"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9 rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32 redis = "0.32.4"
regex = "1.11.1" regex = "1.11.1"
reqwest = { version = "0.12.20", default-features = false } reqwest = { version = "0.12.22", default-features = false }
rgb = "0.8.50" rgb = "0.8.52"
rust_decimal = { version = "1.37.2", features = [ rust_decimal = { version = "1.37.2", features = ["serde-with-float", "serde-with-str"] }
"serde-with-float",
"serde-with-str",
] }
rust_iso3166 = "0.1.14" rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.35.1", default-features = false, features = [ rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err", "fail-on-err",
"tags", "tags",
"tokio-rustls-tls", "tokio-rustls-tls",
] } ] }
rusty-money = "0.4.1" rusty-money = "0.4.1"
sentry = { version = "0.41.0", default-features = false, features = [ sentry = { version = "0.42.0", default-features = false, features = [
"backtrace", "backtrace",
"contexts", "contexts",
"debug-images", "debug-images",
"panic", "panic",
"reqwest", "reqwest",
"rustls", "rustls",
] } ] }
sentry-actix = "0.41.0" sentry-actix = "0.42.0"
serde = "1.0.219" serde = "1.0.219"
serde_bytes = "0.11.17" serde_bytes = "0.11.17"
serde_cbor = "0.11.2" serde_cbor = "0.11.2"
serde_ini = "0.2.0" serde_ini = "0.2.0"
serde_json = "1.0.140" serde_json = "1.0.142"
serde_with = "3.13.0" serde_with = "3.14.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6" sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] } sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9" sha2 = "0.10.9"
spdx = "0.10.8" spdx = "0.10.9"
sqlx = { version = "0.8.6", default-features = false } sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false } sysinfo = { version = "0.36.1", default-features = false }
tar = "0.4.44" tar = "0.4.44"
tauri = "2.6.1" tauri = "2.7.0"
tauri-build = "2.3.0" tauri-build = "2.3.1"
tauri-plugin-deep-link = "2.4.0" tauri-plugin-deep-link = "2.4.1"
tauri-plugin-dialog = "2.3.0" tauri-plugin-dialog = "2.3.2"
tauri-plugin-http = "2.5.0" tauri-plugin-http = "2.5.1"
tauri-plugin-opener = "2.4.0" tauri-plugin-opener = "2.4.0"
tauri-plugin-os = "2.3.0" tauri-plugin-os = "2.3.0"
tauri-plugin-single-instance = "2.3.0" tauri-plugin-single-instance = "2.3.2"
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [ tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls", "rustls-tls",
"zip", "zip",
] } ] }
tauri-plugin-window-state = "2.3.0" tauri-plugin-window-state = "2.4.0"
tempfile = "3.20.0" tempfile = "3.20.0"
theseus = { path = "packages/app-lib" } theseus = { path = "packages/app-lib" }
thiserror = "2.0.12" thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0" tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0" tikv-jemallocator = "0.6.0"
tokio = "1.45.1" tokio = "1.47.1"
tokio-stream = "0.1.17" tokio-stream = "0.1.17"
tokio-util = "0.7.15" tokio-util = "0.7.16"
totp-rs = "5.7.0" totp-rs = "5.7.0"
tracing = "0.1.41" tracing = "0.1.41"
tracing-actix-web = "0.7.18" tracing-actix-web = "0.7.19"
tracing-error = "0.2.1" tracing-error = "0.2.1"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
url = "2.5.4" url = "2.5.4"
@@ -172,11 +174,11 @@ whoami = "1.6.0"
winreg = "0.55.0" winreg = "0.55.0"
woothee = "0.13.0" woothee = "0.13.0"
yaserde = "0.12.0" yaserde = "0.12.0"
zip = { version = "4.2.0", default-features = false, features = [ zip = { version = "4.3.0", default-features = false, features = [
"bzip2", "bzip2",
"deflate", "deflate",
"deflate64", "deflate64",
"zstd", "zstd",
] } ] }
zxcvbn = "3.1.0" zxcvbn = "3.1.0"
@@ -219,15 +221,15 @@ wildcard_dependencies = "warn"
warnings = "deny" warnings = "deny"
[patch.crates-io] [patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" } wry = { git = "https://github.com/modrinth/wry", rev = "f2ce0b0" }
# Optimize for speed and reduce size on release builds # Optimize for speed and reduce size on release builds
[profile.release] [profile.release]
opt-level = "s" # Optimize for binary size opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols strip = true # Remove debug symbols
lto = true # Enables link to optimizations lto = true # Enables link to optimizations
panic = "abort" # Strip expensive panic clean-up logic panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better codegen-units = 1 # Compile crates one after another so the compiler can optimize better
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3

159
README.md
View File

@@ -1,76 +1,123 @@
# Navigation in this README # 📘 Navigation
- [Install instructions](#install-instructions)
- [Features](#features) - [🔧 Install Instructions](#install-instructions)
- [Getting started](#getting-started) - [✨ Features](#features)
- [Disclaimer](#disclaimer) - [🚀 Getting Started](#getting-started)
- [Donate](#support-our-project-crypto-wallets) - [⚠️ Disclaimer](#disclaimer)
- [💰 Donate](#support-our-project-crypto-wallets)
## Other languages
> [Русский](readme/ru_ru/README.md)
## Support channel
> [Telegram](https://me.astralium.su/ref/telegram_channel)
---
# About Project # About Project
## AstralRinth • Empowering Your Minecraft Adventure ## **AstralRinth • Empowering Your Minecraft Adventure**
Welcome to AR • Fork of Modrinth, the ultimate game launcher designed to enhance your Minecraft experience through the Modrinth platform and their API. Whether you're a graphical interface enthusiast, or a developer integrating Modrinth projects, Theseus core is your gateway to a new level of Minecraft gaming.
## About Software Welcome to **AstralRinth (AR)** — a powerful fork of Modrinth, reimagined to enhance your Minecraft journey. Whether you're a GUI enthusiast or a developer building with Modrinths API, **Theseus Core** is your launchpad into a new era of Minecraft gameplay.
Introducing AstralRinth, a specialized variant of Theseus dedicated to implementing offline authorization for an even more flexible and user-centric Minecraft Modrinth experience. Roam the Minecraft realms without the constraints of online authentication, thanks to AstralRinth.
## AR • Unlocking Minecraft's Boundless Horizon - *Recently, improved integration with the Git Astralium API has been added.*
Dive into the extraordinary world of AstralRinth, a fork of the original project with a unique focus on providing a free trial experience for Minecraft, all without the need for a license. Currently boasting:
# Install instructions ## **About the Software**
- To install our application, you need to download a file for your operating system from our available releases or development builds • [Download variants here](https://git.astralium.su/didirus/AstralRinth/releases)
- After you have downloaded the required executable file or archive, then open it
### Downloadable file extensions **AstralRinth** is a dedicated branch of the Theseus project, focused on **offline authentication**, offering you more flexibility and control. Play Minecraft without the need for constant online verification — a user-first approach to modern modded gaming.
- `.msi` format for Windows OS system _(Supported popular latest versions of Microsoft Windows)_
- `.dmg` format for MacOS system _(Works on Macos Ventura / Sonoma / Sequoia, but it should be works on older OS builds)_
- `.deb` format for Linux OS systems _(Since there are quite a few distributions, we do not guarantee
### Installation subjects ## **AR • Unlocking Minecraft's Boundless Horizon**
- Builds in releases that are signed with the following prefixes are not recommended for installation and may contain errors:
- `dev` This unique fork introduces a **free trial Minecraft experience**, bypassing license checks while maintaining rich functionality. Currently includes:
- `nightly`
- `dirty` ---
- `dirty-dev`
- `dirty-nightly` # Install Instructions
- `dirty_dev`
- `dirty_nightly` To install the launcher:
- Auto-updating takes place through parsing special versions from releases, so we also distribute clean types of `.msi, .dmg and .deb`
1. Visit the [releases page](https://git.astralium.su/didirus/AstralRinth/releases) to download the correct version for your system.
2. Run the downloaded file or extract and launch it, depending on the format.
### Downloadable File Extensions
| Extension | OS | Notes |
| --------- | ------- | --------------------------------------------------------------------- |
| `.msi` | Windows | Supported on all recent Windows versions |
| `.dmg` | macOS | Works on Ventura, Sonoma, Sequoia _(may also support older versions)_ |
| `.deb` | Linux | Basic support; compatibility may vary by distribution |
### Installation Warnings
Avoid using builds with these prefixes — they may be unstable or experimental:
- `dev`
- `nightly`
- `dirty`
- `dirty-dev`
- `dirty-nightly`
- `dirty_dev`
- `dirty_nightly`
---
# Features # Features
### Featured enhancement in AR > _The launcher provides an opportunity to use the well-known Modrinth, but with an improved user experience._
- AstralRinth offers a range of authorization options, giving users the flexibility to log in with valid licenses or even a pirate account without auth credentials breaks (_Unlike MultiMC Cracked and similar software_). Experience Minecraft on your terms, breaking free from traditional licensing constraints (_Popular in Russian Federation_).
### Easy to use ## Included exclusive features
- Using the launcher is intuitive, any user can figure it out.
### Update notifies - No ads in the entire launcher.
- We have implemented notifications about the release of new updates on our Git. The launcher can also download them for you and try to install them. - Custom `.svg` vector icons for a distinct UI.
- Improved compatibility with both licensed and pirate accounts.
- Use **official microsoft accounts** or **offline/pirate accounts** — login won't break.
- Supports license-free access for testing or personal use.
- No dependence on official authentication services.
- Discord Rich Presence integration:
- Dynamic status messages.
- In-game timer and AFK counter.
- Strict disabling of statistics and other Modrinth metrics.
- Optimized archive/package size.
- Integrated update fetcher for seamless version management.
- Built-in update alerts for new versions posted on Git Astralium.
- Automatic download and installation capabilities.
- Database migration fixes, when error occurred (Interactive Mode) (Modrinth issue)
- ElyBy skin system integration (AuthLib / Java)
### Enhancements ---
- Custom .SVG vectors for a personalized touch.
- Improved compatibility for both pirate and licensed accounts.
- Beautiful Discord RPC with random messages while playing, along with an in-game timer and AFK counter.
- Forced disabling of statistics collection (modrinch metrics) with a hard patch from AstralRinth, ensuring it remains deactivated regardless of the configuration setting.
- Removal of advertisements from all launcher views.
- Optimization of packages (archives).
- Integrated update fetching feature
# Getting Started # Getting Started
To begin your AstralRinth adventure, follow these steps:
1. **Download Your OS Version**: Head over to our [releases page](https://git.astralium.su/didirus/AstralRinth/releases/) to find the right file for your operating system. To begin using AstralRinth:
- **Choosing the Correct File**: Ensure you select the file that matches your OS requirements.
- [**How select file**](#downloadable-file-extensions) 1. **Download Your OS Version**
- [**How select release**](#installation-subjects)
2. **Authentication**: Log in with a valid license or, for testing, try using a pirate account to see AstralRinth in action. - Go to the [releases page](https://git.astralium.su/didirus/AstralRinth/releases)
3. **Launch Minecraft**: Start your journey by launching Minecraft through AstralRinth and enjoy the adventures that await. - [How to choose a file](#downloadable-file-extensions)
- **Choosing java installation**: The launcher will try to automatically detect the recommended JVM version for running the game, but you can configure everything in the launcher settings. - [How to choose a release](#installation-warnings)
2. **Log In**
- Use your official Mojang/Microsoft account, or test using a non-licensed account.
3. **Launch Minecraft**
- Start Minecraft from the launcher.
- The launcher will auto-detect the recommended JVM version.
- You can also configure Java manually in the settings.
---
# Disclaimer # Disclaimer
- AstralRinth is a project intended for experimentation and educational purposes only. It does not endorse or support piracy, and users are encouraged to obtain valid licenses for a fully-supported Minecraft experience.
- Users are reminded to respect licensing agreements and support the developers of Minecraft.
# Support our Project (Crypto Wallets) - **AstralRinth** is intended **solely for educational and experimental use**.
- We **do not condone piracy** — users are encouraged to purchase a legitimate Minecraft license.
- Respect all relevant licensing agreements and support Minecraft developers.
---
# Support Our Project (Crypto Wallets)
If you'd like to support development, you can donate via the following crypto wallets:
- BTC (Telegram): 14g6asNYzcUoaQtB8B2QGKabgEvn55wfLj - BTC (Telegram): 14g6asNYzcUoaQtB8B2QGKabgEvn55wfLj
- USDT TRC20 (Telegram): TMSmv1D5Fdf4fipUpwBCdh16WevrV45vGr - TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe

View File

@@ -1,2 +1,4 @@
**/dist **/dist
*.gltf *.gltf
src/locales/
src/assets/**/*.svg

View File

@@ -1,22 +1,2 @@
import { createConfigForNuxt } from '@nuxt/eslint-config/flat' import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
import { fixupPluginRules } from '@eslint/compat' export default config
import turboPlugin from 'eslint-plugin-turbo'
export default createConfigForNuxt().append([
{
name: 'turbo',
plugins: {
turbo: fixupPluginRules(turboPlugin),
},
rules: {
'turbo/no-undeclared-env-vars': 'error',
},
},
{
name: 'modrinth',
rules: {
'vue/html-self-closing': 'off',
'vue/multi-word-component-names': 'off',
},
},
])

View File

@@ -1,16 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en" class="dark-mode"> <html lang="en" class="dark-mode">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AstralRinth App</title> <title>AstralRinth App</title>
<link rel="stylesheet" href="/src/assets/stylesheets/global.scss" /> <link rel="stylesheet" href="/src/assets/stylesheets/global.scss" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script src="https://tally.so/widgets/embed.js" async></script>
</body> <script type="module" src="/src/main.js"></script>
</body>
</html> </html>

View File

@@ -1,64 +1,63 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.0-local", "version": "1.0.0-local",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit" "test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@geometrically/minecraft-motd-parser": "^1.1.4", "@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*", "@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*", "@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0", "@sentry/vue": "^8.27.0",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-http": "^2.5.0", "@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-opener": "^2.2.6", "@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2", "@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0", "@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1", "@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0", "@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"ofetch": "^1.3.4", "ofetch": "^1.3.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"posthog-js": "^1.158.2", "posthog-js": "^1.158.2",
"three": "^0.172.0", "three": "^0.172.0",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-multiselect": "3.0.0", "vue-multiselect": "3.0.0",
"vue-router": "4.3.0", "vue-router": "4.3.0",
"vue-virtual-scroller": "v2.0.0-beta.8" "vue-virtual-scroller": "v2.0.0-beta.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.1.1", "@modrinth/tooling-config": "workspace:*",
"@formatjs/cli": "^6.2.12", "@eslint/compat": "^1.1.1",
"@nuxt/eslint-config": "^0.5.6", "@formatjs/cli": "^6.2.12",
"@taijased/vue-render-tracker": "^1.0.7", "@nuxt/eslint-config": "^0.5.6",
"@vitejs/plugin-vue": "^5.0.4", "@taijased/vue-render-tracker": "^1.0.7",
"autoprefixer": "^10.4.19", "@vitejs/plugin-vue": "^5.0.4",
"eslint": "^9.9.1", "autoprefixer": "^10.4.19",
"eslint-config-custom": "workspace:*", "eslint": "^9.9.1",
"eslint-plugin-turbo": "^2.5.4", "eslint-plugin-turbo": "^2.5.4",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"sass": "^1.74.1", "sass": "^1.74.1",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"tsconfig": "workspace:*", "typescript": "^5.5.4",
"typescript": "^5.5.4", "vite": "^5.4.6",
"vite": "^5.4.6", "vue-tsc": "^2.1.6"
"vue-tsc": "^2.1.6" },
}, "packageManager": "pnpm@9.4.0",
"packageManager": "pnpm@9.4.0", "web-types": "../../web-types.json"
"web-types": "../../web-types.json"
} }

View File

@@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,18 @@
export { default as ATLauncherIcon } from './atlauncher.svg'
export { default as BuyMeACoffeeIcon } from './bmac.svg' export { default as BuyMeACoffeeIcon } from './bmac.svg'
export { default as DiscordIcon } from './discord.svg' export { default as DiscordIcon } from './discord.svg'
export { default as GDLauncherIcon } from './gdlauncher.png'
export { default as GithubIcon } from './github.svg'
export { default as GitLabIcon } from './gitlab.svg'
export { default as GoogleIcon } from './google.svg'
export { default as KoFiIcon } from './kofi.svg' export { default as KoFiIcon } from './kofi.svg'
export { default as MastodonIcon } from './mastodon.svg'
export { default as MicrosoftIcon } from './microsoft.svg'
export { default as MultiMCIcon } from './multimc.webp'
export { default as OpenCollectiveIcon } from './opencollective.svg'
export { default as PatreonIcon } from './patreon.svg' export { default as PatreonIcon } from './patreon.svg'
export { default as PaypalIcon } from './paypal.svg' export { default as PaypalIcon } from './paypal.svg'
export { default as OpenCollectiveIcon } from './opencollective.svg'
export { default as TwitterIcon } from './twitter.svg'
export { default as GithubIcon } from './github.svg'
export { default as MastodonIcon } from './mastodon.svg'
export { default as RedditIcon } from './reddit.svg'
export { default as GoogleIcon } from './google.svg'
export { default as MicrosoftIcon } from './microsoft.svg'
export { default as SteamIcon } from './steam.svg'
export { default as GitLabIcon } from './gitlab.svg'
export { default as ATLauncherIcon } from './atlauncher.svg'
export { default as GDLauncherIcon } from './gdlauncher.png'
export { default as MultiMCIcon } from './multimc.webp'
export { default as PrismIcon } from './prism.svg' export { default as PrismIcon } from './prism.svg'
export { default as RedditIcon } from './reddit.svg'
export { default as SteamIcon } from './steam.svg'
export { default as TwitterIcon } from './twitter.svg'

View File

@@ -1,12 +1,12 @@
export { default as SwapIcon } from './arrow-left-right.svg'
export { default as ToggleIcon } from './toggle.svg'
export { default as PackageIcon } from './package.svg'
export { default as VersionIcon } from './milestone.svg'
export { default as TextInputIcon } from './text-cursor-input.svg'
export { default as AddProjectImage } from './add-project.svg' export { default as AddProjectImage } from './add-project.svg'
export { default as NewInstanceImage } from './new-instance.svg' export { default as SwapIcon } from './arrow-left-right.svg'
export { default as MenuIcon } from './menu.svg' export { default as MenuIcon } from './menu.svg'
export { default as ChatIcon } from './messages-square.svg' export { default as ChatIcon } from './messages-square.svg'
export { default as Pirate } from './pirate.svg' export { default as Pirate } from './pirate.svg'
export { default as Microsoft } from './microsoft.svg' export { default as Microsoft } from './microsoft.svg'
export { default as PirateShip } from './pirate-ship.svg' export { default as PirateShip } from './pirate-ship.svg'
export { default as VersionIcon } from './milestone.svg'
export { default as NewInstanceImage } from './new-instance.svg'
export { default as PackageIcon } from './package.svg'
export { default as TextInputIcon } from './text-cursor-input.svg'
export { default as ToggleIcon } from './toggle.svg'

View File

@@ -3,158 +3,158 @@
@tailwind utilities; @tailwind utilities;
@font-face { @font-face {
font-family: 'bundled-minecraft-font-mrapp'; font-family: 'bundled-minecraft-font-mrapp';
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype'); src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
} }
@font-face { @font-face {
font-family: 'bundled-minecraft-font-mrapp'; font-family: 'bundled-minecraft-font-mrapp';
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype'); src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
} }
@font-face { @font-face {
font-family: 'bundled-minecraft-font-mrapp'; font-family: 'bundled-minecraft-font-mrapp';
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 600; font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype'); src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
} }
@font-face { @font-face {
font-family: 'bundled-minecraft-font-mrapp'; font-family: 'bundled-minecraft-font-mrapp';
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 600; font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype'); src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
} }
.font-minecraft { .font-minecraft {
font-family: 'bundled-minecraft-font-mrapp', monospace; font-family: 'bundled-minecraft-font-mrapp', monospace;
} }
:root { :root {
font-family: var(--font-standard, sans-serif), sans-serif; font-family: var(--font-standard, sans-serif), sans-serif;
color-scheme: dark; color-scheme: dark;
--view-width: calc(100% - 5rem); --view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem); --expanded-view-width: calc(100% - 13rem);
} }
body { body {
position: fixed; position: fixed;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
.card-divider { .card-divider {
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
border: none; border: none;
color: var(--color-button-bg); color: var(--color-button-bg);
height: 1px; height: 1px;
margin: var(--gap-sm) 0; margin: var(--gap-sm) 0;
} }
.no-wrap { .no-wrap {
white-space: nowrap; white-space: nowrap;
} }
.no-select { .no-select {
-webkit-user-select: none; -webkit-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }
a { a {
color: var(--color-link); color: var(--color-link);
text-decoration: none; text-decoration: none;
&:hover { &:hover {
text-decoration: none; text-decoration: none;
} }
} }
input { input {
border: none !important; border: none !important;
} }
.badge { .badge {
display: flex; display: flex;
border-radius: var(--radius-md); border-radius: var(--radius-md);
white-space: nowrap; white-space: nowrap;
align-items: center; align-items: center;
background-color: var(--color-bg); background-color: var(--color-bg);
padding-block: var(--gap-sm); padding-block: var(--gap-sm);
padding-inline: var(--gap-lg); padding-inline: var(--gap-lg);
width: min-content; width: min-content;
svg { svg {
width: 1.1rem; width: 1.1rem;
height: 1.1rem; height: 1.1rem;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
&.featured { &.featured {
background-color: var(--color-brand-highlight); background-color: var(--color-brand-highlight);
color: var(--color-contrast); color: var(--color-contrast);
} }
} }
* { * {
scrollbar-width: auto; scrollbar-width: auto;
scrollbar-color: var(--color-scrollbar) var(--color-bg); scrollbar-color: var(--color-scrollbar) var(--color-bg);
} }
/* Chrome, Edge, and Safari */ /* Chrome, Edge, and Safari */
*::-webkit-scrollbar { *::-webkit-scrollbar {
width: 16px; width: 16px;
border: 3px solid transparent; border: 3px solid transparent;
opacity: 0.5; opacity: 0.5;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
*::-webkit-scrollbar:hover { *::-webkit-scrollbar:hover {
opacity: 1; opacity: 1;
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar); background-color: var(--color-scrollbar);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 5px solid transparent; border: 5px solid transparent;
background-clip: content-box; background-clip: content-box;
} }
.highlighted { .highlighted {
box-shadow: 0 0 1rem var(--color-brand) !important; box-shadow: 0 0 1rem var(--color-brand) !important;
} }
.gecko { .gecko {
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
box-shadow: none !important; box-shadow: none !important;
} }
img { img {
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
} }
.card-shadow { .card-shadow {
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
@import '@modrinth/assets/omorphia.scss'; @import '@modrinth/assets/omorphia.scss';

View File

@@ -1,3 +1,3 @@
img { img {
pointer-events: none !important; pointer-events: none !important;
} }

View File

@@ -1,36 +1,38 @@
<script setup> <script setup>
import Instance from '@/components/ui/Instance.vue'
import { computed, ref } from 'vue'
import { import {
ClipboardCopyIcon, ClipboardCopyIcon,
FolderOpenIcon, EyeIcon,
PlayIcon, FolderOpenIcon,
PlusIcon, PlayIcon,
TrashIcon, PlusIcon,
StopCircleIcon, SearchIcon,
EyeIcon, StopCircleIcon,
SearchIcon, TrashIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button, DropdownSelect } from '@modrinth/ui' import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils' import { formatCategoryHeader } from '@modrinth/utils'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { duplicate, remove } from '@/helpers/profile.js' import { computed, ref } from 'vue'
import { handleError } from '@/store/notifications.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { duplicate, remove } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
instances: { instances: {
type: Array, type: Array,
default() { default() {
return [] return []
}, },
}, },
label: { label: {
type: String, type: String,
default: '', default: '',
}, },
}) })
const instanceOptions = ref(null) const instanceOptions = ref(null)
const instanceComponents = ref(null) const instanceComponents = ref(null)
@@ -39,84 +41,84 @@ const currentDeleteInstance = ref(null)
const confirmModal = ref(null) const confirmModal = ref(null)
async function deleteProfile() { async function deleteProfile() {
if (currentDeleteInstance.value) { if (currentDeleteInstance.value) {
instanceComponents.value = instanceComponents.value.filter( instanceComponents.value = instanceComponents.value.filter(
(x) => x.instance.path !== currentDeleteInstance.value, (x) => x.instance.path !== currentDeleteInstance.value,
) )
await remove(currentDeleteInstance.value).catch(handleError) await remove(currentDeleteInstance.value).catch(handleError)
} }
} }
async function duplicateProfile(p) { async function duplicateProfile(p) {
await duplicate(p).catch(handleError) await duplicate(p).catch(handleError)
} }
const handleRightClick = (event, profilePathId) => { const handleRightClick = (event, profilePathId) => {
const item = instanceComponents.value.find((x) => x.instance.path === profilePathId) const item = instanceComponents.value.find((x) => x.instance.path === profilePathId)
const baseOptions = [ const baseOptions = [
{ name: 'add_content' }, { name: 'add_content' },
{ type: 'divider' }, { type: 'divider' },
{ name: 'edit' }, { name: 'edit' },
{ name: 'duplicate' }, { name: 'duplicate' },
{ name: 'open' }, { name: 'open' },
{ name: 'copy' }, { name: 'copy' },
{ type: 'divider' }, { type: 'divider' },
{ {
name: 'delete', name: 'delete',
color: 'danger', color: 'danger',
}, },
] ]
instanceOptions.value.showMenu( instanceOptions.value.showMenu(
event, event,
item, item,
item.playing item.playing
? [ ? [
{ {
name: 'stop', name: 'stop',
color: 'danger', color: 'danger',
}, },
...baseOptions, ...baseOptions,
] ]
: [ : [
{ {
name: 'play', name: 'play',
color: 'primary', color: 'primary',
}, },
...baseOptions, ...baseOptions,
], ],
) )
} }
const handleOptionsClick = async (args) => { const handleOptionsClick = async (args) => {
switch (args.option) { switch (args.option) {
case 'play': case 'play':
args.item.play(null, 'InstanceGridContextMenu') args.item.play(null, 'InstanceGridContextMenu')
break break
case 'stop': case 'stop':
args.item.stop(null, 'InstanceGridContextMenu') args.item.stop(null, 'InstanceGridContextMenu')
break break
case 'add_content': case 'add_content':
await args.item.addContent() await args.item.addContent()
break break
case 'edit': case 'edit':
await args.item.seeInstance() await args.item.seeInstance()
break break
case 'duplicate': case 'duplicate':
if (args.item.instance.install_stage == 'installed') if (args.item.instance.install_stage == 'installed')
await duplicateProfile(args.item.instance.path) await duplicateProfile(args.item.instance.path)
break break
case 'open': case 'open':
await args.item.openFolder() await args.item.openFolder()
break break
case 'copy': case 'copy':
await navigator.clipboard.writeText(args.item.instance.path) await navigator.clipboard.writeText(args.item.instance.path)
break break
case 'delete': case 'delete':
currentDeleteInstance.value = args.item.instance.path currentDeleteInstance.value = args.item.instance.path
confirmModal.value.show() confirmModal.value.show()
break break
} }
} }
const search = ref('') const search = ref('')
@@ -124,250 +126,261 @@ const group = ref('Group')
const sortBy = ref('Name') const sortBy = ref('Name')
const filteredResults = computed(() => { const filteredResults = computed(() => {
const instances = props.instances.filter((instance) => { const instances = props.instances.filter((instance) => {
return instance.name.toLowerCase().includes(search.value.toLowerCase()) return instance.name.toLowerCase().includes(search.value.toLowerCase())
}) })
if (sortBy.value === 'Name') { if (sortBy.value === 'Name') {
instances.sort((a, b) => { instances.sort((a, b) => {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
} }
if (sortBy.value === 'Game version') { if (sortBy.value === 'Game version') {
instances.sort((a, b) => { instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version) return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
}) })
} }
if (sortBy.value === 'Last played') { if (sortBy.value === 'Last played') {
instances.sort((a, b) => { instances.sort((a, b) => {
return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)) return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))
}) })
} }
if (sortBy.value === 'Date created') { if (sortBy.value === 'Date created') {
instances.sort((a, b) => { instances.sort((a, b) => {
return dayjs(b.date_created).diff(dayjs(a.date_created)) return dayjs(b.date_created).diff(dayjs(a.date_created))
}) })
} }
if (sortBy.value === 'Date modified') { if (sortBy.value === 'Date modified') {
instances.sort((a, b) => { instances.sort((a, b) => {
return dayjs(b.date_modified).diff(dayjs(a.date_modified)) return dayjs(b.date_modified).diff(dayjs(a.date_modified))
}) })
} }
const instanceMap = new Map() const instanceMap = new Map()
if (group.value === 'Loader') { if (group.value === 'Loader') {
instances.forEach((instance) => { instances.forEach((instance) => {
const loader = formatCategoryHeader(instance.loader) const loader = formatCategoryHeader(instance.loader)
if (!instanceMap.has(loader)) { if (!instanceMap.has(loader)) {
instanceMap.set(loader, []) instanceMap.set(loader, [])
} }
instanceMap.get(loader).push(instance) instanceMap.get(loader).push(instance)
}) })
} else if (group.value === 'Game version') { } else if (group.value === 'Game version') {
instances.forEach((instance) => { instances.forEach((instance) => {
if (!instanceMap.has(instance.game_version)) { if (!instanceMap.has(instance.game_version)) {
instanceMap.set(instance.game_version, []) instanceMap.set(instance.game_version, [])
} }
instanceMap.get(instance.game_version).push(instance) instanceMap.get(instance.game_version).push(instance)
}) })
} else if (group.value === 'Group') { } else if (group.value === 'Group') {
instances.forEach((instance) => { instances.forEach((instance) => {
if (instance.groups.length === 0) { if (instance.groups.length === 0) {
instance.groups.push('None') instance.groups.push('None')
} }
for (const category of instance.groups) { for (const category of instance.groups) {
if (!instanceMap.has(category)) { if (!instanceMap.has(category)) {
instanceMap.set(category, []) instanceMap.set(category, [])
} }
instanceMap.get(category).push(instance) instanceMap.get(category).push(instance)
} }
}) })
} else { } else {
return instanceMap.set('None', instances) return instanceMap.set('None', instances)
} }
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance // For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A // ie: Category A should come before B, even if the first instance in B comes before the first instance in A
if (sortBy.value === 'Name') { if (sortBy.value === 'Name') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => { const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
// None should always be first // None should always be first
if (a[0] === 'None' && b[0] !== 'None') { if (a[0] === 'None' && b[0] !== 'None') {
return -1 return -1
} }
if (a[0] !== 'None' && b[0] === 'None') { if (a[0] !== 'None' && b[0] === 'None') {
return 1 return 1
} }
return a[0].localeCompare(b[0]) return a[0].localeCompare(b[0])
}) })
instanceMap.clear() instanceMap.clear()
sortedEntries.forEach((entry) => { sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1]) instanceMap.set(entry[0], entry[1])
}) })
} }
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group.value === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap return instanceMap
}) })
</script> </script>
<template> <template>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="iconified-input flex-1"> <div class="iconified-input flex-1">
<SearchIcon /> <SearchIcon />
<input v-model="search" type="text" placeholder="Search" /> <input v-model="search" type="text" placeholder="Search" />
<Button class="r-btn" @click="() => (search = '')"> <Button class="r-btn" @click="() => (search = '')">
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
<DropdownSelect <DropdownSelect
v-slot="{ selected }" v-slot="{ selected }"
v-model="sortBy" v-model="sortBy"
name="Sort Dropdown" name="Sort Dropdown"
class="max-w-[16rem]" class="max-w-[16rem]"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']" :options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..." placeholder="Select..."
> >
<span class="font-semibold text-primary">Sort by: </span> <span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span> <span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect> </DropdownSelect>
<DropdownSelect <DropdownSelect
v-slot="{ selected }" v-slot="{ selected }"
v-model="group" v-model="group"
class="max-w-[16rem]" class="max-w-[16rem]"
name="Group Dropdown" name="Group Dropdown"
:options="['Group', 'Loader', 'Game version', 'None']" :options="['Group', 'Loader', 'Game version', 'None']"
placeholder="Select..." placeholder="Select..."
> >
<span class="font-semibold text-primary">Group by: </span> <span class="font-semibold text-primary">Group by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span> <span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect> </DropdownSelect>
</div> </div>
<div <div
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({ v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key, key,
value, value,
}))" }))"
:key="instanceSection.key" :key="instanceSection.key"
class="row" class="row"
> >
<div v-if="instanceSection.key !== 'None'" class="divider"> <div v-if="instanceSection.key !== 'None'" class="divider">
<p>{{ instanceSection.key }}</p> <p>{{ instanceSection.key }}</p>
<hr aria-hidden="true" /> <hr aria-hidden="true" />
</div> </div>
<section class="instances"> <section class="instances">
<Instance <Instance
v-for="instance in instanceSection.value" v-for="instance in instanceSection.value"
ref="instanceComponents" ref="instanceComponents"
:key="instance.path + instance.install_stage" :key="instance.path + instance.install_stage"
:instance="instance" :instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)" @contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
/> />
</section> </section>
</div> </div>
<ConfirmModalWrapper <ConfirmModalWrapper
ref="confirmModal" ref="confirmModal"
title="Are you sure you want to delete this instance?" title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it." description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false" :has-to-type="false"
proceed-label="Delete" proceed-label="Delete"
@proceed="deleteProfile" @proceed="deleteProfile"
/> />
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick"> <ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template> <template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template> <template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template> <template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template> <template #edit> <EyeIcon /> View instance </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template> <template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #delete> <TrashIcon /> Delete </template> <template #delete> <TrashIcon /> Delete </template>
<template #open> <FolderOpenIcon /> Open folder </template> <template #open> <FolderOpenIcon /> Open folder </template>
<template #copy> <ClipboardCopyIcon /> Copy path </template> <template #copy> <ClipboardCopyIcon /> Copy path </template>
</ContextMenu> </ContextMenu>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.row { .row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
.divider { .divider {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; width: 100%;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
p { p {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
white-space: nowrap; white-space: nowrap;
color: var(--color-contrast); color: var(--color-contrast);
} }
hr { hr {
background-color: var(--color-gray); background-color: var(--color-gray);
height: 1px; height: 1px;
width: 100%; width: 100%;
border: none; border: none;
} }
} }
} }
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
align-items: inherit; align-items: inherit;
margin: 1rem 1rem 0 !important; margin: 1rem 1rem 0 !important;
padding: 1rem; padding: 1rem;
width: calc(100% - 2rem); width: calc(100% - 2rem);
.iconified-input { .iconified-input {
flex-grow: 1; flex-grow: 1;
input { input {
min-width: 100%; min-width: 100%;
} }
} }
.sort-dropdown { .sort-dropdown {
width: 10rem; width: 10rem;
} }
.filter-dropdown { .filter-dropdown {
width: 15rem; width: 15rem;
} }
.group-dropdown { .group-dropdown {
width: 10rem; width: 10rem;
} }
.labeled_button { .labeled_button {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
white-space: nowrap; white-space: nowrap;
} }
} }
.instances { .instances {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
width: 100%; width: 100%;
gap: 0.75rem; gap: 0.75rem;
margin-right: auto; margin-right: auto;
scroll-behavior: smooth; scroll-behavior: smooth;
overflow-y: auto; overflow-y: auto;
} }
</style> </style>

View File

@@ -1,29 +1,30 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useLoading } from '@/store/state.js' import { useLoading } from '@/store/state.js'
const props = defineProps({ const props = defineProps({
throttle: { throttle: {
type: Number, type: Number,
default: 0, default: 0,
}, },
duration: { duration: {
type: Number, type: Number,
default: 1000, default: 1000,
}, },
height: { height: {
type: Number, type: Number,
default: 2, default: 2,
}, },
color: { color: {
type: String, type: String,
default: 'var(--loading-bar-gradient)', default: 'var(--loading-bar-gradient)',
}, },
}) })
const indicator = useLoadingIndicator({ const indicator = useLoadingIndicator({
duration: props.duration, duration: props.duration,
throttle: props.throttle, throttle: props.throttle,
}) })
onBeforeUnmount(() => indicator.clear) onBeforeUnmount(() => indicator.clear)
@@ -31,111 +32,111 @@ onBeforeUnmount(() => indicator.clear)
const loading = useLoading() const loading = useLoading()
watch(loading, (newValue) => { watch(loading, (newValue) => {
if (newValue.barEnabled) { if (newValue.barEnabled) {
if (newValue.loading) { if (newValue.loading) {
indicator.start() indicator.start()
} else { } else {
indicator.finish() indicator.finish()
} }
} }
}) })
function useLoadingIndicator(opts) { function useLoadingIndicator(opts) {
const progress = ref(0) const progress = ref(0)
const isLoading = ref(false) const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration) const step = computed(() => 10000 / opts.duration)
let _timer = null let _timer = null
let _throttle = null let _throttle = null
function start() { function start() {
clear() clear()
progress.value = 0 progress.value = 0
if (opts.throttle) { if (opts.throttle) {
_throttle = setTimeout(() => { _throttle = setTimeout(() => {
isLoading.value = true isLoading.value = true
_startTimer() _startTimer()
}, opts.throttle) }, opts.throttle)
} else { } else {
isLoading.value = true isLoading.value = true
_startTimer() _startTimer()
} }
} }
function finish() { function finish() {
progress.value = 100 progress.value = 100
_hide() _hide()
} }
function clear() { function clear() {
clearInterval(_timer) clearInterval(_timer)
clearTimeout(_throttle) clearTimeout(_throttle)
_timer = null _timer = null
_throttle = null _throttle = null
} }
function _increase(num) { function _increase(num) {
progress.value = Math.min(100, progress.value + num) progress.value = Math.min(100, progress.value + num)
} }
function _hide() { function _hide() {
clear() clear()
setTimeout(() => { setTimeout(() => {
isLoading.value = false isLoading.value = false
setTimeout(() => { setTimeout(() => {
progress.value = 0 progress.value = 0
}, 400) }, 400)
}, 500) }, 500)
} }
function _startTimer() { function _startTimer() {
_timer = setInterval(() => { _timer = setInterval(() => {
_increase(step.value) _increase(step.value)
}, 100) }, 100)
} }
return { progress, isLoading, start, finish, clear } return { progress, isLoading, start, finish, clear }
} }
</script> </script>
<template> <template>
<div <div
class="loading-indicator-bar" class="loading-indicator-bar"
:style="{ :style="{
'--_width': `${indicator.progress.value}%`, '--_width': `${indicator.progress.value}%`,
'--_height': `${indicator.isLoading.value ? props.height : 0}px`, '--_height': `${indicator.isLoading.value ? props.height : 0}px`,
'--_opacity': `${indicator.isLoading.value ? 1 : 0}`, '--_opacity': `${indicator.isLoading.value ? 1 : 0}`,
top: `0`, top: `0`,
right: `0`, right: `0`,
left: `${props.offsetWidth}`, left: `${props.offsetWidth}`,
pointerEvents: 'none', pointerEvents: 'none',
width: `var(--_width)`, width: `var(--_width)`,
height: `var(--_height)`, height: `var(--_height)`,
borderRadius: `var(--_height)`, borderRadius: `var(--_height)`,
// opacity: `var(--_opacity)`, // opacity: `var(--_opacity)`,
background: `${props.color}`, background: `${props.color}`,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`, backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s ease-in-out, height 0.1s ease-out', transition: 'width 0.1s ease-in-out, height 0.1s ease-out',
zIndex: 6, zIndex: 6,
}" }"
> >
<slot /> <slot />
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.loading-indicator-bar::before { .loading-indicator-bar::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
width: var(--_width); width: var(--_width);
bottom: 0; bottom: 0;
background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%); background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%);
opacity: calc(var(--_opacity) * 0.1); opacity: calc(var(--_opacity) * 0.1);
z-index: 5; z-index: 5;
transition: transition:
width 0.1s ease-in-out, width 0.1s ease-in-out,
opacity 0.1s ease-out; opacity 0.1s ease-out;
} }
</style> </style>

View File

@@ -1,52 +1,54 @@
<script setup> <script setup>
import { import {
ClipboardCopyIcon, ClipboardCopyIcon,
FolderOpenIcon, DownloadIcon,
PlayIcon, ExternalIcon,
PlusIcon, EyeIcon,
TrashIcon, FolderOpenIcon,
DownloadIcon, GlobeIcon,
GlobeIcon, PlayIcon,
StopCircleIcon, PlusIcon,
ExternalIcon, StopCircleIcon,
EyeIcon, TrashIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
import Instance from '@/components/ui/Instance.vue' import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ProjectCard from '@/components/ui/ProjectCard.vue'
import { get_by_profile_path } from '@/helpers/process.js'
import { handleError } from '@/store/notifications.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ProjectCard from '@/components/ui/ProjectCard.vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js' import { install as installVersion } from '@/store/install.js'
import { openUrl } from '@tauri-apps/plugin-opener'
import { HeadingLink } from '@modrinth/ui' const { handleError } = injectNotificationManager()
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps({
instances: { instances: {
type: Array, type: Array,
default() { default() {
return [] return []
}, },
}, },
label: { label: {
type: String, type: String,
default: '', default: '',
}, },
canPaginate: Boolean, canPaginate: Boolean,
}) })
const actualInstances = computed(() => const actualInstances = computed(() =>
props.instances.filter( props.instances.filter(
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show, (x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
), ),
) )
const modsRow = ref(null) const modsRow = ref(null)
@@ -58,124 +60,131 @@ const deleteConfirmModal = ref(null)
const currentDeleteInstance = ref(null) const currentDeleteInstance = ref(null)
async function deleteProfile() { async function deleteProfile() {
if (currentDeleteInstance.value) { if (currentDeleteInstance.value) {
await remove(currentDeleteInstance.value).catch(handleError) await remove(currentDeleteInstance.value).catch(handleError)
} }
} }
async function duplicateProfile(p) { async function duplicateProfile(p) {
await duplicate(p).catch(handleError) await duplicate(p).catch(handleError)
} }
const handleInstanceRightClick = async (event, passedInstance) => { const handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [ const baseOptions = [
{ name: 'add_content' }, { name: 'add_content' },
{ type: 'divider' }, { type: 'divider' },
{ name: 'edit' }, { name: 'edit' },
{ name: 'duplicate' }, { name: 'duplicate' },
{ name: 'open_folder' }, { name: 'open_folder' },
{ name: 'copy_path' }, { name: 'copy_path' },
{ type: 'divider' }, { type: 'divider' },
{ {
name: 'delete', name: 'delete',
color: 'danger', color: 'danger',
}, },
] ]
const runningProcesses = await get_by_profile_path(passedInstance.path).catch(handleError) const runningProcesses = await get_by_profile_path(passedInstance.path).catch(handleError)
const options = const options =
runningProcesses.length > 0 runningProcesses.length > 0
? [ ? [
{ {
name: 'stop', name: 'stop',
color: 'danger', color: 'danger',
}, },
...baseOptions, ...baseOptions,
] ]
: [ : [
{ {
name: 'play', name: 'play',
color: 'primary', color: 'primary',
}, },
...baseOptions, ...baseOptions,
] ]
instanceOptions.value.showMenu(event, passedInstance, options) instanceOptions.value.showMenu(event, passedInstance, options)
} }
const handleProjectClick = (event, passedInstance) => { const handleProjectClick = (event, passedInstance) => {
instanceOptions.value.showMenu(event, passedInstance, [ instanceOptions.value.showMenu(event, passedInstance, [
{ {
name: 'install', name: 'install',
color: 'primary', color: 'primary',
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
name: 'open_link', name: 'open_link',
}, },
{ {
name: 'copy_link', name: 'copy_link',
}, },
]) ])
} }
const handleOptionsClick = async (args) => { const handleOptionsClick = async (args) => {
switch (args.option) { switch (args.option) {
case 'play': case 'play':
await run(args.item.path).catch((err) => await run(args.item.path).catch((err) =>
handleSevereError(err, { profilePath: args.item.path }), handleSevereError(err, { profilePath: args.item.path }),
) )
trackEvent('InstanceStart', { trackEvent('InstanceStart', {
loader: args.item.loader, loader: args.item.loader,
game_version: args.item.game_version, game_version: args.item.game_version,
}) })
break break
case 'stop': case 'stop':
await kill(args.item.path).catch(handleError) await kill(args.item.path).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
loader: args.item.loader, loader: args.item.loader,
game_version: args.item.game_version, game_version: args.item.game_version,
}) })
break break
case 'add_content': case 'add_content':
await router.push({ await router.push({
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`, path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: args.item.path }, query: { i: args.item.path },
}) })
break break
case 'edit': case 'edit':
await router.push({ await router.push({
path: `/instance/${encodeURIComponent(args.item.path)}/`, path: `/instance/${encodeURIComponent(args.item.path)}/`,
}) })
break break
case 'duplicate': case 'duplicate':
if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path) if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path)
break break
case 'delete': case 'delete':
currentDeleteInstance.value = args.item.path currentDeleteInstance.value = args.item.path
deleteConfirmModal.value.show() deleteConfirmModal.value.show()
break break
case 'open_folder': case 'open_folder':
await showProfileInFolder(args.item.path) await showProfileInFolder(args.item.path)
break break
case 'copy_path': case 'copy_path':
await navigator.clipboard.writeText(args.item.path) await navigator.clipboard.writeText(args.item.path)
break break
case 'install': { case 'install': {
await installVersion(args.item.project_id, null, null, 'ProjectCardContextMenu') await installVersion(
args.item.project_id,
null,
null,
'ProjectCardContextMenu',
() => {},
() => {},
).catch(handleError)
break break
} }
case 'open_link': case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`) openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
break break
case 'copy_link': case 'copy_link':
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`, `https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
) )
break break
} }
} }
const maxInstancesPerCompactRow = ref(1) const maxInstancesPerCompactRow = ref(1)
@@ -183,184 +192,184 @@ const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1) const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => { const calculateCardsPerRow = () => {
if (rows.value.length === 0) { if (rows.value.length === 0) {
return return
} }
// Calculate how many cards fit in one row // Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem // Convert container width from pixels to rem
const containerWidthInRem = const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize) containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75) maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75) maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75) maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
if (maxInstancesPerRow.value < 5) { if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2 maxInstancesPerRow.value *= 2
} }
if (maxInstancesPerCompactRow.value < 5) { if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2 maxInstancesPerCompactRow.value *= 2
} }
if (maxProjectsPerRow.value < 3) { if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2 maxProjectsPerRow.value *= 2
} }
} }
const rowContainer = ref(null) const rowContainer = ref(null)
const resizeObserver = ref(null) const resizeObserver = ref(null)
onMounted(() => { onMounted(() => {
calculateCardsPerRow() calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow) resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
if (rowContainer.value) { if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value) resizeObserver.value.observe(rowContainer.value)
} }
window.addEventListener('resize', calculateCardsPerRow) window.addEventListener('resize', calculateCardsPerRow)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow) window.removeEventListener('resize', calculateCardsPerRow)
if (rowContainer.value) { if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value) resizeObserver.value.unobserve(rowContainer.value)
} }
}) })
</script> </script>
<template> <template>
<ConfirmModalWrapper <ConfirmModalWrapper
ref="deleteConfirmModal" ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?" title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it." description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false" :has-to-type="false"
proceed-label="Delete" proceed-label="Delete"
@proceed="deleteProfile" @proceed="deleteProfile"
/> />
<div ref="rowContainer" class="flex flex-col gap-4"> <div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row"> <div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route"> <HeadingLink class="mt-1" :to="row.route">
{{ row.label }} {{ row.label }}
</HeadingLink> </HeadingLink>
<section <section
v-if="row.instance" v-if="row.instance"
ref="modsRow" ref="modsRow"
class="instances" class="instances"
:class="{ compact: row.compact }" :class="{ compact: row.compact }"
> >
<Instance <Instance
v-for="(instance, instanceIndex) in row.instances.slice( v-for="(instance, instanceIndex) in row.instances.slice(
0, 0,
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow, row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
)" )"
:key="row.label + instance.path" :key="row.label + instance.path"
:instance="instance" :instance="instance"
:compact="row.compact" :compact="row.compact"
:first="instanceIndex === 0" :first="instanceIndex === 0"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)" @contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/> />
</section> </section>
<section v-else ref="modsRow" class="projects"> <section v-else ref="modsRow" class="projects">
<ProjectCard <ProjectCard
v-for="project in row.instances.slice(0, maxProjectsPerRow)" v-for="project in row.instances.slice(0, maxProjectsPerRow)"
:key="project?.project_id" :key="project?.project_id"
ref="instanceComponents" ref="instanceComponents"
class="item" class="item"
:project="project" :project="project"
@contextmenu.prevent.stop="(event) => handleProjectClick(event, project)" @contextmenu.prevent.stop="(event) => handleProjectClick(event, project)"
/> />
</section> </section>
</div> </div>
</div> </div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick"> <ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template> <template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template> <template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template> <template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template> <template #edit> <EyeIcon /> View instance </template>
<template #delete> <TrashIcon /> Delete </template> <template #delete> <TrashIcon /> Delete </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template> <template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template> <template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template> <template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #install> <DownloadIcon /> Install </template> <template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template> <template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template> <template #copy_link> <ClipboardCopyIcon /> Copy link </template>
</ContextMenu> </ContextMenu>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.content { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
gap: 1rem; gap: 1rem;
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 0; width: 0;
background: transparent; background: transparent;
} }
} }
.row { .row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;
&:nth-child(even) { &:nth-child(even) {
background: var(--color-bg); background: var(--color-bg);
} }
.header { .header {
width: 100%; width: 100%;
margin-bottom: 1rem; margin-bottom: 1rem;
gap: var(--gap-xs); gap: var(--gap-xs);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
a { a {
margin: 0; margin: 0;
font-size: var(--font-size-md); font-size: var(--font-size-md);
font-weight: bolder; font-weight: bolder;
white-space: nowrap; white-space: nowrap;
color: var(--color-base); color: var(--color-base);
} }
svg { svg {
height: 1.25rem; height: 1.25rem;
width: 1.25rem; width: 1.25rem;
color: var(--color-base); color: var(--color-base);
} }
} }
.instances { .instances {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-gap: 0.75rem; grid-gap: 0.75rem;
width: 100%; width: 100%;
&.compact { &.compact {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 0.75rem; gap: 0.75rem;
} }
} }
.projects { .projects {
display: grid; display: grid;
width: 100%; width: 100%;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 0.75rem; grid-gap: 0.75rem;
.item { .item {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
} }
} }
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
import { DropdownIcon, PlusIcon, FolderOpenIcon } from '@modrinth/assets' import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, OverflowMenu } from '@modrinth/ui' import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { add_project_from_path } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { add_project_from_path } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
instance: { instance: {
type: Object, type: Object,
required: true, required: true,
}, },
}) })
const router = useRouter() const router = useRouter()
const handleAddContentFromFile = async () => { const handleAddContentFromFile = async () => {
const newProject = await open({ multiple: true }) const newProject = await open({ multiple: true })
if (!newProject) return if (!newProject) return
for (const project of newProject) { for (const project of newProject) {
await add_project_from_path(props.instance.path, project.path ?? project).catch(handleError) await add_project_from_path(props.instance.path, project.path ?? project).catch(handleError)
} }
} }
const handleSearchContent = async () => { const handleSearchContent = async () => {
await router.push({ await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`, path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.path }, query: { i: props.instance.path },
}) })
} }
</script> </script>
<template> <template>
<div class="joined-buttons"> <div class="joined-buttons">
<ButtonStyled> <ButtonStyled>
<button @click="handleSearchContent"> <button @click="handleSearchContent">
<PlusIcon /> <PlusIcon />
Install content Install content
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<OverflowMenu <OverflowMenu
:options="[ :options="[
{ {
id: 'from_file', id: 'from_file',
action: handleAddContentFromFile, action: handleAddContentFromFile,
}, },
]" ]"
> >
<DropdownIcon /> <DropdownIcon />
<template #from_file> <template #from_file>
<FolderOpenIcon /> <FolderOpenIcon />
<span class="no-wrap"> Add from file </span> <span class="no-wrap"> Add from file </span>
</template> </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
</div> </div>
</template> </template>

View File

@@ -1,63 +1,64 @@
<template> <template>
<div data-tauri-drag-region class="flex items-center gap-1 pl-3"> <div data-tauri-drag-region class="flex items-center gap-1 pl-3">
<Button v-if="false" class="breadcrumbs__back transparent" icon-only @click="$router.back()"> <Button v-if="false" class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon /> <ChevronLeftIcon />
</Button> </Button>
<Button <Button
v-if="false" v-if="false"
class="breadcrumbs__forward transparent" class="breadcrumbs__forward transparent"
icon-only icon-only
@click="$router.forward()" @click="$router.forward()"
> >
<ChevronRightIcon /> <ChevronRightIcon />
</Button> </Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }} {{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name"> <template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link <router-link
v-if="breadcrumb.link" v-if="breadcrumb.link"
:to="{ :to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)), path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query, query: breadcrumb.query,
}" }"
class="text-primary" class="text-primary"
>{{ >{{
breadcrumb.name.charAt(0) === '?' breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1)) ? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name : breadcrumb.name
}} }}
</router-link> </router-link>
<span <span
v-else v-else
data-tauri-drag-region data-tauri-drag-region
class="text-contrast font-semibold cursor-default select-none" class="text-contrast font-semibold cursor-default select-none"
>{{ >{{
breadcrumb.name.charAt(0) === '?' breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1)) ? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name : breadcrumb.name
}}</span }}</span
> >
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" /> <ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" />
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ChevronRightIcon, ChevronLeftIcon } from '@modrinth/assets' import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui' import { Button } from '@modrinth/ui'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useRoute } from 'vue-router'
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const route = useRoute() const route = useRoute()
const breadcrumbData = useBreadcrumbs() const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
const additionalContext = const additionalContext =
route.meta.useContext === true route.meta.useContext === true
? breadcrumbData.context ? breadcrumbData.context
: route.meta.useRootContext === true : route.meta.useRootContext === true
? breadcrumbData.rootContext ? breadcrumbData.rootContext
: null : null
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
}) })
</script> </script>

View File

@@ -1,18 +1,26 @@
<template> <template>
<transition name="fade"> <transition name="fade">
<div v-show="shown" ref="contextMenu" class="context-menu" :style="{ <div
left: left, v-show="shown"
top: top, ref="contextMenu"
}"> class="context-menu"
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)"> :style="{
<hr v-if="option.type === 'divider'" class="divider" /> left: left,
<div v-else-if="!(isLinkedData(item) && option.name === `add_content`)" class="item clickable" top: top,
:class="[option.color ?? 'base']"> }"
<slot :name="option.name" /> >
</div> <div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
</div> <hr v-if="option.type === 'divider'" class="divider" />
</div> <div
</transition> v-else-if="!(isLinkedData(item) && option.name === `add_content`)"
class="item clickable"
:class="[option.color ?? 'base']"
>
<slot :name="option.name" />
</div>
</div>
</div>
</transition>
</template> </template>
<script setup> <script setup>
@@ -28,141 +36,141 @@ const top = ref('0px')
const shown = ref(false) const shown = ref(false)
defineExpose({ defineExpose({
showMenu: (event, passedItem, passedOptions) => { showMenu: (event, passedItem, passedOptions) => {
item.value = passedItem item.value = passedItem
options.value = passedOptions options.value = passedOptions
const menuWidth = contextMenu.value.clientWidth const menuWidth = contextMenu.value.clientWidth
const menuHeight = contextMenu.value.clientHeight const menuHeight = contextMenu.value.clientHeight
if (menuWidth + event.pageX >= window.innerWidth) { if (menuWidth + event.pageX >= window.innerWidth) {
left.value = event.pageX - menuWidth + 2 + 'px' left.value = event.pageX - menuWidth + 2 + 'px'
} else { } else {
left.value = event.pageX - 2 + 'px' left.value = event.pageX - 2 + 'px'
} }
if (menuHeight + event.pageY >= window.innerHeight) { if (menuHeight + event.pageY >= window.innerHeight) {
top.value = event.pageY - menuHeight + 2 + 'px' top.value = event.pageY - menuHeight + 2 + 'px'
} else { } else {
top.value = event.pageY - 2 + 'px' top.value = event.pageY - 2 + 'px'
} }
shown.value = true shown.value = true
}, },
}) })
const isLinkedData = (item) => { const isLinkedData = (item) => {
if (item.instance != undefined && item.instance.linked_data) { if (item.instance != undefined && item.instance.linked_data) {
return true return true
} else if (item != undefined && item.linked_data) { } else if (item != undefined && item.linked_data) {
return true return true
} }
return false return false
} }
const hideContextMenu = () => { const hideContextMenu = () => {
shown.value = false shown.value = false
emit('menu-closed') emit('menu-closed')
} }
const optionClicked = (option) => { const optionClicked = (option) => {
emit('option-clicked', { emit('option-clicked', {
item: item.value, item: item.value,
option: option, option: option,
}) })
hideContextMenu() hideContextMenu()
} }
const onEscKeyRelease = (event) => { const onEscKeyRelease = (event) => {
if (event.keyCode === 27) { if (event.keyCode === 27) {
hideContextMenu() hideContextMenu()
} }
} }
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY) const elements = document.elementsFromPoint(event.clientX, event.clientY)
if ( if (
contextMenu.value && contextMenu.value &&
contextMenu.value.$el !== event.target && contextMenu.value.$el !== event.target &&
!elements.includes(contextMenu.value.$el) !elements.includes(contextMenu.value.$el)
) { ) {
hideContextMenu() hideContextMenu()
} }
} }
onMounted(() => { onMounted(() => {
window.addEventListener('click', handleClickOutside) window.addEventListener('click', handleClickOutside)
document.body.addEventListener('keyup', onEscKeyRelease) document.body.addEventListener('keyup', onEscKeyRelease)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside) window.removeEventListener('click', handleClickOutside)
document.removeEventListener('keyup', onEscKeyRelease) document.removeEventListener('keyup', onEscKeyRelease)
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.context-menu { .context-menu {
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-floating); box-shadow: var(--shadow-floating);
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
margin: 0; margin: 0;
position: fixed; position: fixed;
z-index: 1000000; z-index: 1000000;
overflow: hidden; overflow: hidden;
padding: var(--gap-sm); padding: var(--gap-sm);
.item { .item {
align-items: center; align-items: center;
color: var(--color-base); color: var(--color-base);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
gap: var(--gap-sm); gap: var(--gap-sm);
padding: var(--gap-sm); padding: var(--gap-sm);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
&:hover, &:hover,
&:active { &:active {
&.base { &.base {
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
color: var(--color-contrast); color: var(--color-contrast);
} }
&.primary { &.primary {
background-color: var(--color-brand); background-color: var(--color-brand);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
font-weight: bold; font-weight: bold;
} }
&.danger { &.danger {
background-color: var(--color-red); background-color: var(--color-red);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
font-weight: bold; font-weight: bold;
} }
&.contrast { &.contrast {
background-color: var(--color-orange); background-color: var(--color-orange);
color: var(--color-accent-contrast); color: var(--color-accent-contrast);
font-weight: bold; font-weight: bold;
} }
} }
} }
.divider { .divider {
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
margin: var(--gap-sm); margin: var(--gap-sm);
pointer-events: none; pointer-events: none;
} }
} }
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
.fade-enter-from, .fade-enter-from,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
</style> </style>

View File

@@ -0,0 +1,380 @@
<template>
<ModalWrapper ref="modal" :header="'Import from CurseForge Profile Code'">
<div class="modal-body">
<div class="input-row">
<p class="input-label">Profile Code</p>
<div class="iconified-input">
<SearchIcon aria-hidden="true" class="text-lg" />
<input ref="codeInput" v-model="profileCode" autocomplete="off" class="h-12 card-shadow"
spellcheck="false" type="text" placeholder="Enter CurseForge profile code" maxlength="20"
@keyup.enter="importProfile" />
<Button v-if="profileCode" class="r-btn" @click="() => (profileCode = '')">
<XIcon />
</Button>
</div>
</div>
<div v-if="metadata && !importing" class="profile-info">
<h3>Profile Information</h3>
<p><strong>Name:</strong> {{ metadata.name }}</p>
</div>
<div v-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<div v-if="importing && importProgress.visible" class="progress-section">
<div class="progress-info">
<span class="progress-text">{{ importProgress.message }}</span>
<span class="progress-percentage">{{ Math.floor(importProgress.percentage) }}%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${importProgress.percentage}%` }"></div>
</div>
</div>
<div class="button-row">
<Button @click="hide" :disabled="importing">
<XIcon />
Cancel
</Button>
<Button v-if="!metadata" @click="fetchMetadata" :disabled="!profileCode.trim() || fetching"
color="secondary">
<SearchIcon v-if="!fetching" />
{{ fetching ? 'Checking...' : 'Check Profile' }}
</Button>
<Button v-if="metadata" @click="importProfile" :disabled="importing" color="primary">
<DownloadIcon v-if="!importing" />
{{ importing ? 'Importing...' : 'Import Profile' }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { Button } from '@modrinth/ui'
import {
XIcon,
SearchIcon,
DownloadIcon
} from '@modrinth/assets'
import {
fetch_curseforge_profile_metadata,
import_curseforge_profile
} from '@/helpers/import.js'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener } from '@/helpers/events.js'
const props = defineProps({
closeParent: {
type: Function,
default: null
}
})
const router = useRouter()
const modal = ref(null)
const codeInput = ref(null)
const profileCode = ref('')
const metadata = ref(null)
const fetching = ref(false)
const importing = ref(false)
const error = ref('')
const importProgress = ref({
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
})
let unlistenLoading = null
let activeLoadingBarId = null
let progressFallbackTimer = null
defineExpose({
show: () => {
profileCode.value = ''
metadata.value = null
fetching.value = false
importing.value = false
error.value = ''
importProgress.value = {
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
modal.value?.show()
nextTick(() => {
setTimeout(() => {
codeInput.value?.focus()
}, 100)
})
trackEvent('CurseForgeProfileImportStart', { source: 'ImportModal' })
},
})
const hide = () => {
modal.value?.hide()
}
const fetchMetadata = async () => {
if (!profileCode.value.trim()) return
fetching.value = true
error.value = ''
try {
const result = await fetch_curseforge_profile_metadata(profileCode.value.trim())
metadata.value = result
trackEvent('CurseForgeProfileMetadataFetched', {
profileCode: profileCode.value.trim()
})
} catch (err) {
console.error('Failed to fetch CurseForge profile metadata:', err)
error.value = 'Failed to fetch profile information. Please check the code and try again.'
handleError(err)
} finally {
fetching.value = false
}
}
const importProfile = async () => {
if (!profileCode.value.trim()) return
importing.value = true
error.value = ''
activeLoadingBarId = null // Reset for new import session
importProgress.value = {
visible: true,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
// Fallback progress timer in case loading events don't work
progressFallbackTimer = setInterval(() => {
if (importing.value && importProgress.value.percentage < 90) {
// Slowly increment progress as a fallback
importProgress.value.percentage = Math.min(90, importProgress.value.percentage + 1)
}
}, 1000)
try {
const { result, profilePath } = await import_curseforge_profile(profileCode.value.trim())
trackEvent('CurseForgeProfileImported', {
profileCode: profileCode.value.trim()
})
hide()
// Close the parent modal if provided
if (props.closeParent) {
props.closeParent()
}
// Navigate to the imported profile
await router.push(`/instance/${encodeURIComponent(profilePath)}`)
} catch (err) {
console.error('Failed to import CurseForge profile:', err)
error.value = 'Failed to import profile. Please try again.'
handleError(err)
} finally {
importing.value = false
importProgress.value.visible = false
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
progressFallbackTimer = null
}
activeLoadingBarId = null
}
}
onMounted(async () => {
// Listen for loading events to update progress
unlistenLoading = await loading_listener((event) => {
console.log('Loading event received:', event) // Debug log
// Handle all loading events that could be related to CurseForge profile import
const isCurseForgeEvent = event.event?.type === 'curseforge_profile_download'
const hasProfileName = event.event?.profile_name && importing.value
if ((isCurseForgeEvent || hasProfileName) && importing.value) {
// Store the loading bar ID for this import session
if (!activeLoadingBarId) {
activeLoadingBarId = event.loader_uuid
}
// Only process events for our current import session
if (event.loader_uuid === activeLoadingBarId) {
if (event.fraction !== null && event.fraction !== undefined) {
const baseProgress = (event.fraction || 0) * 100
// Calculate custom progress based on the message
let finalProgress = baseProgress
const message = event.message || 'Importing profile...'
// Custom progress calculation for different stages
if (message.includes('Fetching') || message.includes('metadata')) {
finalProgress = Math.min(10, baseProgress)
} else if (message.includes('Downloading profile ZIP') || message.includes('profile ZIP')) {
finalProgress = Math.min(15, 10 + (baseProgress - 10) * 0.5)
} else if (message.includes('Extracting') || message.includes('ZIP')) {
finalProgress = Math.min(20, 15 + (baseProgress - 15) * 0.5)
} else if (message.includes('Configuring') || message.includes('profile')) {
finalProgress = Math.min(30, 20 + (baseProgress - 20) * 0.5)
} else if (message.includes('Copying') || message.includes('files')) {
finalProgress = Math.min(40, 30 + (baseProgress - 30) * 0.5)
} else if (message.includes('Downloaded mod') && message.includes(' of ')) {
// Parse "Downloaded mod X of Y" message
const match = message.match(/Downloaded mod (\d+) of (\d+)/)
if (match) {
const current = parseInt(match[1])
const total = parseInt(match[2])
// Mods take 40% of progress (from 40% to 80%)
const modProgress = (current / total) * 40
finalProgress = 40 + modProgress
} else {
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.5)
}
} else if (message.includes('Downloading mod') || message.includes('mods')) {
// General mod downloading stage (40% to 80%)
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.4)
} else if (message.includes('Installing Minecraft') || message.includes('Minecraft')) {
finalProgress = Math.min(95, 80 + (baseProgress - 80) * 0.75)
} else if (message.includes('Finalizing') || message.includes('completed')) {
finalProgress = Math.min(100, 95 + (baseProgress - 95))
} else {
// Default: use the base progress but ensure minimum progression
finalProgress = Math.max(importProgress.value.percentage, baseProgress)
}
importProgress.value.percentage = Math.min(100, Math.max(0, finalProgress))
importProgress.value.message = message
} else {
// Loading complete
importProgress.value.percentage = 100
importProgress.value.message = 'Import completed!'
activeLoadingBarId = null
}
}
}
})
})
onUnmounted(() => {
if (unlistenLoading) {
unlistenLoading()
}
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
}
})
</script>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.input-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-label {
font-weight: 600;
color: var(--color-contrast);
margin: 0;
}
.profile-info {
background: var(--color-bg);
border: 1px solid var(--color-button);
border-radius: var(--radius-md);
padding: 1rem;
h3 {
margin: 0 0 0.5rem 0;
color: var(--color-contrast);
}
p {
margin: 0.25rem 0;
color: var(--color-base);
}
}
.error-message {
background: var(--color-red);
border: 1px solid var(--color-red);
border-radius: var(--radius-md);
padding: 0.75rem;
p {
margin: 0;
color: var(--color-contrast);
font-weight: 500;
}
}
.progress-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
.progress-text {
color: var(--color-base);
font-size: 0.875rem;
}
.progress-percentage {
color: var(--color-contrast);
font-size: 0.875rem;
font-weight: 600;
}
}
.progress-bar-container {
width: 100%;
height: 4px;
background: var(--color-button);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--color-brand);
border-radius: 2px;
transition: width 0.3s ease;
min-width: 0;
}
.button-row {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: auto;
}
</style>

View File

@@ -1,28 +1,37 @@
<script setup> <script setup>
import { import {
CheckIcon, CheckIcon,
DropdownIcon, CopyIcon,
XIcon, DropdownIcon,
HammerIcon, HammerIcon,
LogInIcon, LogInIcon,
UpdatedIcon, UpdatedIcon,
CopyIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons' import { ChatIcon } from '@/assets/icons'
import { ButtonStyled, Collapsible } from '@modrinth/ui'
import { ref, computed } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { install } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install } from '@/helpers/profile.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { handleSevereError } from '@/store/error.js'
// [AR] Feature
import { applyMigrationFix } from '@/helpers/utils.js'
import { restartApp } from '@/helpers/utils.js'
const { handleError } = injectNotificationManager()
const errorModal = ref() const errorModal = ref()
const error = ref() const error = ref()
const closable = ref(true) const closable = ref(true)
const errorCollapsed = ref(false) const errorCollapsed = ref(false)
const language = ref('en')
const migrationFixSuccess = ref(null) // null | true | false
const migrationFixCallbackModel = ref()
const title = ref('An error occurred') const title = ref('An error occurred')
const errorType = ref('unknown') const errorType = ref('unknown')
@@ -30,111 +39,111 @@ const supportLink = ref('https://support.modrinth.com')
const metadata = ref({}) const metadata = ref({})
defineExpose({ defineExpose({
async show(errorVal, context, canClose = true, source = null) { async show(errorVal, context, canClose = true, source = null) {
closable.value = canClose closable.value = canClose
if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) { if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) {
title.value = 'Unable to sign in to Minecraft' title.value = 'Unable to sign in to Minecraft'
errorType.value = 'minecraft_auth' errorType.value = 'minecraft_auth'
supportLink.value = supportLink.value =
'https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues' 'https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues'
if ( if (
errorVal.message.includes('existing connection was forcibly closed') || errorVal.message.includes('existing connection was forcibly closed') ||
errorVal.message.includes('error sending request for url') errorVal.message.includes('error sending request for url')
) { ) {
metadata.value.network = true metadata.value.network = true
} }
if (errorVal.message.includes('because the target machine actively refused it')) { if (errorVal.message.includes('because the target machine actively refused it')) {
metadata.value.hostsFile = true metadata.value.hostsFile = true
} }
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) { } else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
title.value = 'Sign in to Minecraft' title.value = 'Sign in to Minecraft'
errorType.value = 'minecraft_sign_in' errorType.value = 'minecraft_sign_in'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
} else if (errorVal.message && errorVal.message.includes('Move directory error:')) { } else if (errorVal.message && errorVal.message.includes('Move directory error:')) {
title.value = 'Could not change app directory' title.value = 'Could not change app directory'
errorType.value = 'directory_move' errorType.value = 'directory_move'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
if (errorVal.message.includes('directory is not writeable')) { if (errorVal.message.includes('directory is not writeable')) {
metadata.value.readOnly = true metadata.value.readOnly = true
} }
if (errorVal.message.includes('Not enough space')) { if (errorVal.message.includes('Not enough space')) {
metadata.value.notEnoughSpace = true metadata.value.notEnoughSpace = true
} }
} else if (errorVal.message && errorVal.message.includes('No loader version selected for')) { } else if (errorVal.message && errorVal.message.includes('No loader version selected for')) {
title.value = 'No loader selected' title.value = 'No loader selected'
errorType.value = 'no_loader_version' errorType.value = 'no_loader_version'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
metadata.value.profilePath = context.profilePath metadata.value.profilePath = context.profilePath
} else if (source === 'state_init') { } else if (source === 'state_init') {
title.value = 'Error initializing Modrinth App' title.value = 'Error initializing Modrinth App'
errorType.value = 'state_init' errorType.value = 'state_init'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
} else { } else {
title.value = 'An error occurred' title.value = 'An error occurred'
errorType.value = 'unknown' errorType.value = 'unknown'
supportLink.value = 'https://support.modrinth.com' supportLink.value = 'https://support.modrinth.com'
metadata.value = {} metadata.value = {}
} }
error.value = errorVal error.value = errorVal
errorModal.value.show() errorModal.value.show()
}, },
}) })
const loadingMinecraft = ref(false) const loadingMinecraft = ref(false)
async function loginMinecraft() { async function loginMinecraft() {
try { try {
loadingMinecraft.value = true loadingMinecraft.value = true
const loggedIn = await login_flow() const loggedIn = await login_flow()
if (loggedIn) { if (loggedIn) {
await set_default_user(loggedIn.profile.id).catch(handleError) await set_default_user(loggedIn.profile.id).catch(handleError)
} }
await trackEvent('AccountLogIn', { source: 'ErrorModal' }) await trackEvent('AccountLogIn', { source: 'ErrorModal' })
loadingMinecraft.value = false loadingMinecraft.value = false
errorModal.value.hide() errorModal.value.hide()
} catch (err) { } catch (err) {
loadingMinecraft.value = false loadingMinecraft.value = false
handleSevereError(err) handleSevereError(err)
} }
} }
async function cancelDirectoryChange() { async function cancelDirectoryChange() {
try { try {
await cancel_directory_change() await cancel_directory_change()
window.location.reload() window.location.reload()
} catch (err) { } catch (err) {
handleError(err) handleError(err)
} }
} }
function retryDirectoryChange() { function retryDirectoryChange() {
window.location.reload() window.location.reload()
} }
const loadingRepair = ref(false) const loadingRepair = ref(false)
async function repairInstance() { async function repairInstance() {
loadingRepair.value = true loadingRepair.value = true
try { try {
await install(metadata.value.profilePath, false) await install(metadata.value.profilePath, false)
errorModal.value.hide() errorModal.value.hide()
} catch (err) { } catch (err) {
handleSevereError(err) handleSevereError(err)
} }
loadingRepair.value = false loadingRepair.value = false
} }
const hasDebugInfo = computed( const hasDebugInfo = computed(
() => () =>
errorType.value === 'directory_move' || errorType.value === 'directory_move' ||
errorType.value === 'minecraft_auth' || errorType.value === 'minecraft_auth' ||
errorType.value === 'state_init' || errorType.value === 'state_init' ||
errorType.value === 'no_loader_version', errorType.value === 'no_loader_version',
) )
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.') const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
@@ -142,236 +151,373 @@ const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error
const copied = ref(false) const copied = ref(false)
async function copyToClipboard(text) { async function copyToClipboard(text) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
copied.value = true copied.value = true
setTimeout(() => { setTimeout(() => {
copied.value = false copied.value = false
}, 3000) }, 3000)
} }
function toggleLanguage() {
language.value = language.value === 'en' ? 'ru' : 'en'
}
async function onApplyMigrationFix(eol) {
console.log(`[AR] • Attempting to apply migration ${eol.toUpperCase()} fix`)
try {
const result = await applyMigrationFix(eol)
migrationFixSuccess.value = result === true
console.log(`[AR] • Successfully applied migration ${eol.toUpperCase()} fix`, result)
} catch (err) {
console.error(`[AR] • Failed to apply migration fix:`, err)
migrationFixSuccess.value = false
} finally {
migrationFixCallbackModel.value?.show?.()
if (migrationFixSuccess.value === true) {
setTimeout(async () => {
await restartApp()
}, 3000)
}
}
}
</script> </script>
<template> <template>
<ModalWrapper ref="errorModal" :header="title" :closable="closable"> <ModalWrapper ref="errorModal" :header="title" :closable="closable">
<div class="modal-body"> <div class="modal-body">
<div class="markdown-body"> <div class="markdown-body">
<template v-if="errorType === 'minecraft_auth'"> <template v-if="errorType === 'minecraft_auth'">
<template v-if="metadata.network"> <template v-if="metadata.network">
<h3>Network issues</h3> <h3>Network issues</h3>
<p> <p>
It looks like there were issues with the Modrinth App connecting to Microsoft's It looks like there were issues with the Modrinth App connecting to Microsoft's
servers. This is often the result of a poor connection, so we recommend trying again servers. This is often the result of a poor connection, so we recommend trying again
to see if it works. If issues continue to persist, follow the steps in to see if it works. If issues continue to persist, follow the steps in
<a <a
href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_e71a5f805f" href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_e71a5f805f"
> >
our support article our support article
</a> </a>
to troubleshoot. to troubleshoot.
</p> </p>
</template> </template>
<template v-else-if="metadata.hostsFile"> <template v-else-if="metadata.hostsFile">
<h3>Network issues</h3> <h3>Network issues</h3>
<p> <p>
The Modrinth App tried to connect to Microsoft / Xbox / Minecraft services, but the The Modrinth App tried to connect to Microsoft / Xbox / Minecraft services, but the
remote server rejected the connection. This may indicate that these services are remote server rejected the connection. This may indicate that these services are
blocked by the hosts file. Please visit blocked by the hosts file. Please visit
<a <a
href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_d694a29256" href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_d694a29256"
> >
our support article our support article
</a> </a>
for steps on how to fix the issue. for steps on how to fix the issue.
</p> </p>
</template> </template>
<template v-else> <template v-else>
<h3>Try another Microsoft account</h3> <h3>Try another Microsoft account</h3>
<p> <p>
Double check you've signed in with the right account. You may own Minecraft on a Double check you've signed in with the right account. You may own Minecraft on a
different Microsoft account. different Microsoft account.
</p> </p>
<div class="cta-button"> <div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft"> <button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Try another account <LogInIcon /> Try another account
</button> </button>
</div> </div>
<h3>Using PC Game Pass, coming from Bedrock, or just bought the game?</h3> <h3>Using PC Game Pass, coming from Bedrock, or just bought the game?</h3>
<p> <p>
Try signing in with the Try signing in with the
<a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>
first. Once you're done, come back here and sign in! first. Once you're done, come back here and sign in!
</p> </p>
</template> </template>
<div class="cta-button"> <div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft"> <button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Try signing in again <LogInIcon /> Try signing in again
</button> </button>
</div> </div>
</template> </template>
<template v-if="errorType === 'directory_move'"> <template v-if="errorType === 'directory_move'">
<template v-if="metadata.readOnly"> <template v-if="metadata.readOnly">
<h3>Change directory permissions</h3> <h3>Change directory permissions</h3>
<p> <p>
It looks like the Modrinth App is unable to write to the directory you selected. It looks like the Modrinth App is unable to write to the directory you selected.
Please adjust the permissions of the directory and try again or cancel the directory Please adjust the permissions of the directory and try again or cancel the directory
change. change.
</p> </p>
</template> </template>
<template v-else-if="metadata.notEnoughSpace"> <template v-else-if="metadata.notEnoughSpace">
<h3>Not enough space</h3> <h3>Not enough space</h3>
<p> <p>
It looks like there is not enough space on the disk containing the directory you It looks like there is not enough space on the disk containing the directory you
selected. Please free up some space and try again or cancel the directory change. selected. Please free up some space and try again or cancel the directory change.
</p> </p>
</template> </template>
<template v-else> <template v-else>
<p> <p>
The Modrinth App is unable to migrate to the new directory you selected. Please The Modrinth App is unable to migrate to the new directory you selected. Please
contact support for help or cancel the directory change. contact support for help or cancel the directory change.
</p> </p>
</template> </template>
<div class="cta-button"> <div class="cta-button">
<button class="btn" @click="retryDirectoryChange"> <button class="btn" @click="retryDirectoryChange">
<UpdatedIcon /> Retry directory change <UpdatedIcon /> Retry directory change
</button> </button>
<button class="btn btn-danger" @click="cancelDirectoryChange"> <button class="btn btn-danger" @click="cancelDirectoryChange">
<XIcon /> Cancel directory change <XIcon /> Cancel directory change
</button> </button>
</div> </div>
</template> </template>
<div v-else-if="errorType === 'minecraft_sign_in'"> <div v-else-if="errorType === 'minecraft_sign_in'">
<p> <p>
To play this instance, you must sign in through Microsoft below. If you don't have a To play this instance, you must sign in through Microsoft below. If you don't have a
Minecraft account, you can purchase the game on the Minecraft account, you can purchase the game on the
<a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc" <a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
>Minecraft website</a >Minecraft website</a
>. >.
</p> </p>
<div class="cta-button"> <div class="cta-button">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft"> <button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> Sign in to Minecraft <LogInIcon /> Sign in to Minecraft
</button> </button>
</div> </div>
</div> </div>
<template v-else-if="errorType === 'state_init'"> <template v-else-if="errorType === 'state_init'">
<p> <p>
Modrinth App failed to load correctly. This may be because of a corrupted file, or Modrinth App failed to load correctly. This may be because of a corrupted file, or
because the app is missing crucial files. because the app is missing crucial files.
</p> </p>
<p>You may be able to fix it through one of the following ways:</p> <p>You may be able to fix it through one of the following ways:</p>
<ul> <ul>
<li>Ensuring you are connected to the internet, then try restarting the app.</li> <li>Ensuring you are connected to the internet, then try restarting the app.</li>
<li>Redownloading the app.</li> <li>Redownloading the app.</li>
</ul> </ul>
</template> </template>
<template v-else-if="errorType === 'no_loader_version'"> <template v-else-if="errorType === 'no_loader_version'">
<p>The Modrinth App failed to find the loader version for this instance.</p> <p>The Modrinth App failed to find the loader version for this instance.</p>
<p>To resolve this, you need to repair the instance. Click the button below to do so.</p> <p>To resolve this, you need to repair the instance. Click the button below to do so.</p>
<div class="cta-button"> <div class="cta-button">
<button class="btn btn-primary" :disabled="loadingRepair" @click="repairInstance"> <button class="btn btn-primary" :disabled="loadingRepair" @click="repairInstance">
<HammerIcon /> Repair instance <HammerIcon /> Repair instance
</button> </button>
</div> </div>
</template> </template>
<template v-else> <template v-else>
{{ debugInfo }} {{ debugInfo }}
</template> </template>
<template v-if="hasDebugInfo"> <template v-if="hasDebugInfo">
<hr /> <hr />
<p> <p>
If nothing is working and you need help, visit If nothing is working and you need help, visit
<a :href="supportLink">our support page</a> <a :href="supportLink">our support page</a>
and start a chat using the widget in the bottom right and we will be more than happy to and start a chat using the widget in the bottom right and we will be more than happy to
assist! Make sure to provide the following debug information to the agent: assist! Make sure to provide the following debug information to the agent:
</p> </p>
</template> </template>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ButtonStyled> <ButtonStyled>
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a> <a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-if="closable"> <ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button> <button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-if="hasDebugInfo"> <ButtonStyled v-if="hasDebugInfo">
<button :disabled="copied" @click="copyToClipboard(debugInfo)"> <button :disabled="copied" @click="copyToClipboard(debugInfo)">
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template> <template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
<template v-else> <CopyIcon /> Copy debug info </template> <template v-else> <CopyIcon /> Copy debug info </template>
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<template v-if="hasDebugInfo"> <template v-if="hasDebugInfo">
<div class="bg-button-bg rounded-xl mt-2 overflow-clip"> <div class="bg-button-bg rounded-xl mt-2 overflow-clip">
<button <button
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer" class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
@click="errorCollapsed = !errorCollapsed" @click="errorCollapsed = !errorCollapsed"
> >
<span class="text-contrast font-extrabold m-0">Debug information:</span> <span class="text-contrast font-extrabold m-0">Debug information:</span>
<DropdownIcon <DropdownIcon
class="h-5 w-5 text-secondary transition-transform" class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !errorCollapsed }" :class="{ 'rotate-180': !errorCollapsed }"
/> />
</button> </button>
<Collapsible :collapsed="errorCollapsed"> <Collapsible :collapsed="errorCollapsed">
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre> <pre class="m-0 px-4 py-3 bg-bg rounded-none whitespace-pre-wrap break-words overflow-x-auto max-w-full"
</Collapsible> >{{ debugInfo }}</pre>
</div> </Collapsible>
</template> </div>
</div> <template v-if="errorType === 'state_init'">
</ModalWrapper> <div class="notice">
<div class="flex justify-between items-center">
<h3 v-if="language === 'en'" class="notice__title"> Migration Issue Important Notice </h3>
<h3 v-if="language === 'ru'" class="notice__title"> Проблема миграции Важное уведомление </h3>
<ButtonStyled>
<button @click="toggleLanguage">
{{ language === 'en' ? '📖 Русский' : '📖 English' }}
</button>
</ButtonStyled>
</div>
<p v-if="language === 'en'" class="notice__text">
We're experiencing an issue with our database migration system due to differences in how different operating systems handle line endings. This might cause problems with our app's functionality.
</p>
<p v-if="language === 'en'" class="notice__text">
<strong>What's happening?</strong> When we build our app, we use a system that checks the integrity of our database migrations. However, this system can get confused when it encounters different line endings (like CRLF vs LF) used by different operating systems. This can lead to errors and make our app unusable.
</p>
<p v-if="language === 'en'" class="notice__text">
<strong>Why is this happening?</strong> This issue is caused by a combination of factors, including different operating systems handling line endings differently, Git's line ending conversion settings, and our app's build process.
</p>
<p v-if="language === 'en'" class="notice__text">
<strong>What are we doing about it?</strong> We're working to resolve this issue and ensure that our app works smoothly for all users. In the meantime, we apologize for any inconvenience this might cause and appreciate your patience and understanding.
</p>
<p v-if="language === 'ru'" class="notice__text">
Мы сталкиваемся с проблемой в нашей системе миграции базы данных из-за различий в том, как разные операционные системы обрабатывают окончания строк. Это может вызвать проблемы с функциональностью нашего приложения.
</p>
<p v-if="language === 'ru'" class="notice__text">
<strong>Что происходит?</strong> Когда мы строим наше приложение, мы используем систему, которая проверяет целостность наших миграций базы данных. Однако эта система может сбиваться, когда сталкивается с различными окончаниями строк (например, CRLF против LF), используемыми разными операционными системами. Это может привести к ошибкам и сделать наше приложение неработоспособным.
</p>
<p v-if="language === 'ru'" class="notice__text">
<strong>Почему это происходит?</strong> Эта проблема вызвана сочетанием факторов, включая различную обработку окончаний строк разными операционными системами, настройки преобразования окончаний строк в Git и процесс сборки нашего приложения.
</p>
<p v-if="language === 'ru'" class="notice__text">
<strong>Что мы с этим делаем?</strong> Мы работаем над решением этой проблемы и обеспечением бесперебойной работы нашего приложения для всех пользователей. В это время мы извиняемся за возможные неудобства и благодарим вас за терпение и понимание.
</p>
</div>
<h2 class="text-lg font-bold text-contrast">
<template v-if="language === 'en'">Possible fix in real time:</template>
<template v-if="language === 'ru'">Возможное исправление в реальном времени:</template>
</h2>
<div class="flex justify-between">
<ol class="flex flex-col gap-3">
<li>
<ButtonStyled class="neon-button neon">
<button
:title="language === 'en'
? 'Convert all line endings in migration files to LF (Unix-style: \\n)'
: 'Преобразовать все окончания строк в файлах миграций в LF (Unix-стиль: \\n)'"
aria-label="LF"
@click="onApplyMigrationFix('lf')"
>
{{ language === 'en' ? 'Apply LF Migration Fix' : 'Применить исправление миграции LF' }}
</button>
</ButtonStyled>
</li>
<li>
<ButtonStyled class="neon-button neon">
<button
:title="language === 'en'
? 'Convert all line endings in migration files to CRLF (Windows-style: \\r\\n)'
: 'Преобразовать все окончания строк в файлах миграций в CRLF (Windows-стиль: \\r\\n)'"
aria-label="CRLF"
@click="onApplyMigrationFix('crlf')"
>
{{ language === 'en' ? 'Apply CRLF Migration Fix' : 'Применить исправление миграции CRLF' }}
</button>
</ButtonStyled>
</li>
</ol>
</div>
</template>
</template>
</div>
</ModalWrapper>
<ModalWrapper
ref="migrationFixCallbackModel"
:header="language === 'en'
? '💡 Migration fix report'
: '💡 Отчет об исправлении миграции'"
:closable="closable">
<div class="modal-body">
<h2 class="text-lg font-bold text-contrast space-y-2">
<template v-if="migrationFixSuccess === true">
<p class="flex items-center gap-2 neon-text">
{{ language === 'en'
? 'The migration fix has been applied successfully. Please restart the launcher and try to log in to the game :)'
: 'Исправление миграции успешно применено. Пожалуйста, перезапустите лаунчер и попробуйте снова авторизоваться в игре :)' }}
</p>
<p class="mt-2 text-sm neon-text">
{{ language === 'en'
? 'If the problem persists, please try the other fix.'
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
</p>
</template>
<template v-else-if="migrationFixSuccess === false">
<p class="flex items-center gap-2 neon-text">
{{ language === 'en'
? 'The migration fix failed or had no effect.'
: 'Исправление миграции не было успешно применено или не имело эффекта.' }}
</p>
<p class="mt-2 text-sm neon-text">
{{ language === 'en'
? 'If the problem persists, please try the other fix.'
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
</p>
</template>
</h2>
</div>
</ModalWrapper>
</template> </template>
<style> <style>
.light-mode { .light-mode {
--color-orange-bg: rgba(255, 163, 71, 0.2); --color-orange-bg: rgba(255, 163, 71, 0.2);
} }
.dark-mode, .dark-mode,
.oled-mode { .oled-mode {
--color-orange-bg: rgba(224, 131, 37, 0.2); --color-orange-bg: rgba(224, 131, 37, 0.2);
} }
</style> </style>
<style scoped lang="scss"> <style scoped lang="scss">
@import '../../../../../packages/assets/styles/neon-button.scss';
@import '../../../../../packages/assets/styles/neon-text.scss';
.cta-button { .cta-button {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.5rem; padding: 0.5rem;
gap: 0.5rem; gap: 0.5rem;
} }
.warning-banner { .warning-banner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
padding: var(--gap-lg); padding: var(--gap-lg);
background-color: var(--color-orange-bg); background-color: var(--color-orange-bg);
border: 2px solid var(--color-orange); border: 2px solid var(--color-orange);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.warning-banner__title { .warning-banner__title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-weight: 700; font-weight: 700;
svg { svg {
color: var(--color-orange); color: var(--color-orange);
height: 1.5rem; height: 1.5rem;
width: 1.5rem; width: 1.5rem;
} }
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-md); gap: var(--gap-md);
} }
.markdown-body { .markdown-body {
overflow: auto; overflow: auto;
} }
</style> </style>

View File

@@ -1,25 +1,27 @@
<script setup> <script setup>
import { XIcon, PlusIcon } from '@modrinth/assets' import { PlusIcon, XIcon } from '@modrinth/assets'
import { Button, Checkbox } from '@modrinth/ui' import { Button, Checkbox, injectNotificationManager } from '@modrinth/ui'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import { ref } from 'vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { handleError } from '@/store/notifications.js' import { ref } from 'vue'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
instance: { instance: {
type: Object, type: Object,
required: true, required: true,
}, },
}) })
defineExpose({ defineExpose({
show: () => { show: () => {
exportModal.value.show() exportModal.value.show()
initFiles() initFiles()
}, },
}) })
const exportModal = ref(null) const exportModal = ref(null)
@@ -31,273 +33,273 @@ const folders = ref([])
const showingFiles = ref(false) const showingFiles = ref(false)
const initFiles = async () => { const initFiles = async () => {
const newFolders = new Map() const newFolders = new Map()
const sep = '/' const sep = '/'
files.value = [] files.value = []
await get_pack_export_candidates(props.instance.path).then((filePaths) => await get_pack_export_candidates(props.instance.path).then((filePaths) =>
filePaths filePaths
.map((folder) => ({ .map((folder) => ({
path: folder, path: folder,
name: folder.split(sep).pop(), name: folder.split(sep).pop(),
selected: selected:
folder.startsWith('mods') || folder.startsWith('mods') ||
folder.startsWith('datapacks') || folder.startsWith('datapacks') ||
folder.startsWith('resourcepacks') || folder.startsWith('resourcepacks') ||
folder.startsWith('shaderpacks') || folder.startsWith('shaderpacks') ||
folder.startsWith('config'), folder.startsWith('config'),
disabled: disabled:
folder === 'profile.json' || folder === 'profile.json' ||
folder.startsWith('modrinth_logs') || folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric'), folder.startsWith('.fabric'),
})) }))
.filter((pathData) => !pathData.path.includes('.DS_Store')) .filter((pathData) => !pathData.path.includes('.DS_Store'))
.forEach((pathData) => { .forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep) const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') { if (parent !== '') {
if (newFolders.has(parent)) { if (newFolders.has(parent)) {
newFolders.get(parent).push(pathData) newFolders.get(parent).push(pathData)
} else { } else {
newFolders.set(parent, [pathData]) newFolders.set(parent, [pathData])
} }
} else { } else {
files.value.push(pathData) files.value.push(pathData)
} }
}), }),
) )
folders.value = [...newFolders.entries()].map(([name, value]) => [ folders.value = [...newFolders.entries()].map(([name, value]) => [
{ {
name, name,
showingMore: false, showingMore: false,
}, },
value, value,
]) ])
} }
await initFiles() await initFiles()
const exportPack = async () => { const exportPack = async () => {
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path) const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
folders.value.forEach((args) => { folders.value.forEach((args) => {
args[1].forEach((child) => { args[1].forEach((child) => {
if (child.selected) { if (child.selected) {
filesToExport.push(child.path) filesToExport.push(child.path)
} }
}) })
}) })
const outputPath = await open({ const outputPath = await open({
directory: true, directory: true,
multiple: false, multiple: false,
}) })
if (outputPath) { if (outputPath) {
export_profile_mrpack( export_profile_mrpack(
props.instance.path, props.instance.path,
outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`, outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`,
filesToExport, filesToExport,
versionInput.value, versionInput.value,
exportDescription.value, exportDescription.value,
nameInput.value, nameInput.value,
).catch((err) => handleError(err)) ).catch((err) => handleError(err))
exportModal.value.hide() exportModal.value.hide()
} }
} }
</script> </script>
<template> <template>
<ModalWrapper ref="exportModal" header="Export modpack"> <ModalWrapper ref="exportModal" header="Export modpack">
<div class="modal-body"> <div class="modal-body">
<div class="labeled_input"> <div class="labeled_input">
<p>Modpack Name</p> <p>Modpack Name</p>
<div class="iconified-input"> <div class="iconified-input">
<PackageIcon /> <PackageIcon />
<input v-model="nameInput" type="text" placeholder="Modpack name" class="input" /> <input v-model="nameInput" type="text" placeholder="Modpack name" class="input" />
<Button class="r-btn" @click="nameInput = ''"> <Button class="r-btn" @click="nameInput = ''">
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
</div> </div>
<div class="labeled_input"> <div class="labeled_input">
<p>Version number</p> <p>Version number</p>
<div class="iconified-input"> <div class="iconified-input">
<VersionIcon /> <VersionIcon />
<input v-model="versionInput" type="text" placeholder="1.0.0" class="input" /> <input v-model="versionInput" type="text" placeholder="1.0.0" class="input" />
<Button class="r-btn" @click="versionInput = ''"> <Button class="r-btn" @click="versionInput = ''">
<XIcon /> <XIcon />
</Button> </Button>
</div> </div>
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<div class="labeled_input"> <div class="labeled_input">
<p>Description</p> <p>Description</p>
<div class="textarea-wrapper"> <div class="textarea-wrapper">
<textarea v-model="exportDescription" placeholder="Enter modpack description..." /> <textarea v-model="exportDescription" placeholder="Enter modpack description..." />
</div> </div>
</div> </div>
</div> </div>
<div class="table"> <div class="table">
<div class="table-head"> <div class="table-head">
<div class="table-cell row-wise"> <div class="table-cell row-wise">
Select files and folders to include in pack Select files and folders to include in pack
<Button <Button
class="sleek-primary collapsed-button" class="sleek-primary collapsed-button"
icon-only icon-only
@click="() => (showingFiles = !showingFiles)" @click="() => (showingFiles = !showingFiles)"
> >
<PlusIcon v-if="!showingFiles" /> <PlusIcon v-if="!showingFiles" />
<XIcon v-else /> <XIcon v-else />
</Button> </Button>
</div> </div>
</div> </div>
<div v-if="showingFiles" class="table-content"> <div v-if="showingFiles" class="table-content">
<div v-for="[path, children] in folders" :key="path.name" class="table-row"> <div v-for="[path, children] in folders" :key="path.name" class="table-row">
<div class="table-cell file-entry"> <div class="table-cell file-entry">
<div class="file-primary"> <div class="file-primary">
<Checkbox <Checkbox
:model-value="children.every((child) => child.selected)" :model-value="children.every((child) => child.selected)"
:label="path.name" :label="path.name"
class="select-checkbox" class="select-checkbox"
:disabled="children.every((x) => x.disabled)" :disabled="children.every((x) => x.disabled)"
@update:model-value=" @update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue)) (newValue) => children.forEach((child) => (child.selected = newValue))
" "
/> />
<Checkbox <Checkbox
v-model="path.showingMore" v-model="path.showingMore"
class="select-checkbox dropdown" class="select-checkbox dropdown"
collapsing-toggle-style collapsing-toggle-style
/> />
</div> </div>
<div v-if="path.showingMore" class="file-secondary"> <div v-if="path.showingMore" class="file-secondary">
<div v-for="child in children" :key="child.path" class="file-secondary-row"> <div v-for="child in children" :key="child.path" class="file-secondary-row">
<Checkbox <Checkbox
v-model="child.selected" v-model="child.selected"
:label="child.name" :label="child.name"
class="select-checkbox" class="select-checkbox"
:disabled="child.disabled" :disabled="child.disabled"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div v-for="file in files" :key="file.path" class="table-row"> <div v-for="file in files" :key="file.path" class="table-row">
<div class="table-cell file-entry"> <div class="table-cell file-entry">
<div class="file-primary"> <div class="file-primary">
<Checkbox <Checkbox
v-model="file.selected" v-model="file.selected"
:label="file.name" :label="file.name"
:disabled="file.disabled" :disabled="file.disabled"
class="select-checkbox" class="select-checkbox"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="button-row push-right"> <div class="button-row push-right">
<Button @click="exportModal.hide"> <Button @click="exportModal.hide">
<XIcon /> <XIcon />
Cancel Cancel
</Button> </Button>
<Button color="primary" @click="exportPack"> <Button color="primary" @click="exportPack">
<PackageIcon /> <PackageIcon />
Export Export
</Button> </Button>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-md); gap: var(--gap-md);
} }
.labeled_input { .labeled_input {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-sm); gap: var(--gap-sm);
p { p {
margin: 0; margin: 0;
} }
} }
.select-checkbox { .select-checkbox {
gap: var(--gap-sm); gap: var(--gap-sm);
button.checkbox { button.checkbox {
border: none; border: none;
} }
&.dropdown { &.dropdown {
margin-left: auto; margin-left: auto;
} }
} }
.table-content { .table-content {
max-height: 18rem; max-height: 18rem;
overflow-y: auto; overflow-y: auto;
} }
.table { .table {
border: 1px solid var(--color-bg); border: 1px solid var(--color-bg);
} }
.file-entry { .file-entry {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-sm); gap: var(--gap-sm);
} }
.file-primary { .file-primary {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--gap-sm); gap: var(--gap-sm);
} }
.file-secondary { .file-secondary {
margin-left: var(--gap-xl); margin-left: var(--gap-xl);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-sm); gap: var(--gap-sm);
height: 100%; height: 100%;
vertical-align: center; vertical-align: center;
} }
.file-secondary-row { .file-secondary-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--gap-sm); gap: var(--gap-sm);
} }
.button-row { .button-row {
display: flex; display: flex;
gap: var(--gap-sm); gap: var(--gap-sm);
align-items: center; align-items: center;
} }
.row-wise { .row-wise {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
} }
.textarea-wrapper { .textarea-wrapper {
// margin-top: 1rem; // margin-top: 1rem;
height: 12rem; height: 12rem;
textarea { textarea {
max-height: 12rem; max-height: 12rem;
} }
.preview { .preview {
overflow-y: auto; overflow-y: auto;
} }
} }
</style> </style>

View File

@@ -1,51 +1,52 @@
<script setup> <script setup>
import {
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import {
DownloadIcon, import { trackEvent } from '@/helpers/analytics'
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { handleError } from '@/store/state.js' import { get_by_profile_path } from '@/helpers/process'
import { finish_install, kill, run } from '@/helpers/profile'
import { showProfileInFolder } from '@/helpers/utils.js' import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs'
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const props = defineProps({ const props = defineProps({
instance: { instance: {
type: Object, type: Object,
default() { default() {
return {} return {}
}, },
}, },
compact: { compact: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
first: { first: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}) })
const playing = ref(false) const playing = ref(false)
const loading = ref(false) const loading = ref(false)
const modLoading = computed( const modLoading = computed(
() => () =>
loading.value || loading.value ||
currentEvent.value === 'installing' || currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !playing.value), (currentEvent.value === 'launched' && !playing.value),
) )
const installing = computed(() => props.instance.install_stage.includes('installing')) const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed') const installed = computed(() => props.instance.install_stage === 'installed')
@@ -53,78 +54,78 @@ const installed = computed(() => props.instance.install_stage === 'installed')
const router = useRouter() const router = useRouter()
const seeInstance = async () => { const seeInstance = async () => {
await router.push(`/instance/${encodeURIComponent(props.instance.path)}`) await router.push(`/instance/${encodeURIComponent(props.instance.path)}`)
} }
const checkProcess = async () => { const checkProcess = async () => {
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError) const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
playing.value = runningProcesses.length > 0 playing.value = runningProcesses.length > 0
} }
const play = async (e, context) => { const play = async (e, context) => {
e?.stopPropagation() e?.stopPropagation()
loading.value = true loading.value = true
await run(props.instance.path) await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path })) .catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => { .finally(() => {
trackEvent('InstancePlay', { trackEvent('InstancePlay', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
source: context, source: context,
}) })
}) })
loading.value = false loading.value = false
} }
const stop = async (e, context) => { const stop = async (e, context) => {
e?.stopPropagation() e?.stopPropagation()
playing.value = false playing.value = false
await kill(props.instance.path).catch(handleError) await kill(props.instance.path).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
source: context, source: context,
}) })
} }
const repair = async (e) => { const repair = async (e) => {
e?.stopPropagation() e?.stopPropagation()
await finish_install(props.instance) await finish_install(props.instance).catch(handleError)
} }
const openFolder = async () => { const openFolder = async () => {
await showProfileInFolder(props.instance.path) await showProfileInFolder(props.instance.path)
} }
const addContent = async () => { const addContent = async () => {
await router.push({ await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`, path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.path }, query: { i: props.instance.path },
}) })
} }
defineExpose({ defineExpose({
play, play,
stop, stop,
seeInstance, seeInstance,
openFolder, openFolder,
addContent, addContent,
instance: props.instance, instance: props.instance,
}) })
const currentEvent = ref(null) const currentEvent = ref(null)
const unlisten = await process_listener((e) => { const unlisten = await process_listener((e) => {
if (e.profile_path_id === props.instance.path) { if (e.profile_path_id === props.instance.path) {
currentEvent.value = e.event currentEvent.value = e.event
if (e.event === 'finished') { if (e.event === 'finished') {
playing.value = false playing.value = false
} }
} }
}) })
onMounted(() => checkProcess()) onMounted(() => checkProcess())
@@ -132,118 +133,118 @@ onUnmounted(() => unlisten())
</script> </script>
<template> <template>
<template v-if="compact"> <template v-if="compact">
<div <div
class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all" class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all"
@click="seeInstance" @click="seeInstance"
@mouseenter="checkProcess" @mouseenter="checkProcess"
> >
<Avatar <Avatar
size="48px" size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path" :tint-by="instance.path"
alt="Mod card" alt="Mod card"
/> />
<div class="h-full flex items-center font-bold text-contrast leading-normal"> <div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span> <span class="line-clamp-2">{{ instance.name }}</span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess"> <ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')"> <button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
<StopCircleIcon /> <StopCircleIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular> <ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="'Instance is loading...'" disabled> <button v-tooltip="'Instance is loading...'" disabled>
<SpinnerIcon class="animate-spin" /> <SpinnerIcon class="animate-spin" />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular> <ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
<button <button
v-tooltip="'Play'" v-tooltip="'Play'"
@click="(e) => play(e, 'InstanceCard')" @click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess" @mousehover="checkProcess"
> >
<!-- Translate for optical centering --> <!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" /> <PlayIcon class="translate-x-[1px]" />
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon /> <TimerIcon />
<span class="text-sm"> <span class="text-sm">
<template v-if="instance.last_played"> <template v-if="instance.last_played">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }} Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</template> </template>
<template v-else> Never played </template> <template v-else> Never played </template>
</span> </span>
</div> </div>
</div> </div>
</template> </template>
<div v-else> <div v-else>
<div <div
class="button-base bg-bg-raised p-4 rounded-xl flex gap-3 group" class="button-base bg-bg-raised p-4 rounded-xl flex gap-3 group"
@click="seeInstance" @click="seeInstance"
@mouseenter="checkProcess" @mouseenter="checkProcess"
> >
<div class="relative flex items-center justify-center"> <div class="relative flex items-center justify-center">
<Avatar <Avatar
size="48px" size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.path" :tint-by="instance.path"
alt="Mod card" alt="Mod card"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`" :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"> <div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="playing" size="large" color="red" circular> <ButtonStyled v-if="playing" size="large" color="red" circular>
<button <button
v-tooltip="'Stop'" v-tooltip="'Stop'"
:class="{ 'scale-100 opacity-100': playing }" :class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow" class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')" @click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess" @mousehover="checkProcess"
> >
<StopCircleIcon /> <StopCircleIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<SpinnerIcon <SpinnerIcon
v-else-if="modLoading || installing" v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'" v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="animate-spin w-8 h-8" class="animate-spin w-8 h-8"
tabindex="-1" tabindex="-1"
/> />
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular> <ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<button <button
v-tooltip="'Repair'" 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" 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)" @click="(e) => repair(e)"
> >
<DownloadIcon /> <DownloadIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular> <ButtonStyled v-else size="large" color="brand" circular>
<button <button
v-tooltip="'Play'" 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" 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')" @click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess" @mousehover="checkProcess"
> >
<PlayIcon class="translate-x-[2px]" /> <PlayIcon class="translate-x-[2px]" />
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1"> <p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
{{ instance.name }} {{ instance.name }}
</p> </p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
<GameIcon class="shrink-0" /> <GameIcon class="shrink-0" />
<span class="text-sm capitalize"> <span class="text-sm capitalize">
{{ instance.loader }} {{ instance.game_version }} {{ instance.loader }} {{ instance.game_version }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core'
import { formatCategory } from '@modrinth/utils'
import { GameIcon, LeftArrowIcon } from '@modrinth/assets' import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled } from '@modrinth/ui' import { Avatar, ButtonStyled } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
type Instance = { type Instance = {
game_version: string game_version: string
loader: string loader: string
path: string path: string
install_stage: string install_stage: string
icon_path?: string icon_path?: string
name: string name: string
} }
defineProps<{ defineProps<{
instance: Instance instance: Instance
}>() }>()
</script> </script>
<template> <template>
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4"> <div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link <router-link
:to="`/instance/${encodeURIComponent(instance.path)}`" :to="`/instance/${encodeURIComponent(instance.path)}`"
tabindex="-1" tabindex="-1"
class="flex flex-col gap-4 text-primary" class="flex flex-col gap-4 text-primary"
> >
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<Avatar <Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:alt="instance.name" :alt="instance.name"
size="48px" size="48px"
/> />
<span class="flex flex-col gap-2"> <span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast"> <span class="font-extrabold bold text-contrast">
{{ instance.name }} {{ instance.name }}
</span> </span>
<span class="text-secondary flex items-center gap-2 font-semibold"> <span class="text-secondary flex items-center gap-2 font-semibold">
<GameIcon class="h-5 w-5 text-secondary" /> <GameIcon class="h-5 w-5 text-secondary" />
{{ formatCategory(instance.loader) }} {{ instance.game_version }} {{ formatCategory(instance.loader) }} {{ instance.game_version }}
</span> </span>
</span> </span>
</span> </span>
</router-link> </router-link>
<ButtonStyled> <ButtonStyled>
<router-link :to="`/instance/${encodeURIComponent(instance.path)}`"> <router-link :to="`/instance/${encodeURIComponent(instance.path)}`">
<LeftArrowIcon /> Back to instance <LeftArrowIcon /> Back to instance
</router-link> </router-link>
</ButtonStyled> </ButtonStyled>
</div> </div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -1,95 +1,97 @@
<template> <template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false"> <ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<div class="auto-detect-modal"> <div class="auto-detect-modal">
<div class="table"> <div class="table">
<div class="table-row table-head"> <div class="table-row table-head">
<div class="table-cell table-text">Version</div> <div class="table-cell table-text">Version</div>
<div class="table-cell table-text">Path</div> <div class="table-cell table-text">Path</div>
<div class="table-cell table-text">Actions</div> <div class="table-cell table-text">Actions</div>
</div> </div>
<div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row"> <div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row">
<div class="table-cell table-text"> <div class="table-cell table-text">
<span>{{ javaInstall.version }}</span> <span>{{ javaInstall.version }}</span>
</div> </div>
<div v-tooltip="javaInstall.path" class="table-cell table-text"> <div v-tooltip="javaInstall.path" class="table-cell table-text">
<span>{{ javaInstall.path }}</span> <span>{{ javaInstall.path }}</span>
</div> </div>
<div class="table-cell table-text manage"> <div class="table-cell table-text manage">
<Button v-if="currentSelected.path === javaInstall.path" disabled <Button v-if="currentSelected.path === javaInstall.path" disabled
><CheckIcon /> Selected</Button ><CheckIcon /> Selected</Button
> >
<Button v-else @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</Button> <Button v-else @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</Button>
</div> </div>
</div> </div>
<div v-if="chosenInstallOptions.length === 0" class="table-row entire-row"> <div v-if="chosenInstallOptions.length === 0" class="table-row entire-row">
<div class="table-cell table-text">No java installations found!</div> <div class="table-cell table-text">No java installations found!</div>
</div> </div>
</div> </div>
<div class="input-group push-right"> <div class="input-group push-right">
<Button @click="$refs.detectJavaModal.hide()"> <Button @click="$refs.detectJavaModal.hide()">
<XIcon /> <XIcon />
Cancel Cancel
</Button> </Button>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<script setup> <script setup>
import { PlusIcon, CheckIcon, XIcon } from '@modrinth/assets' import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui' import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
import { find_filtered_jres } from '@/helpers/jre.js'
import { handleError } from '@/store/notifications.js'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.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 chosenInstallOptions = ref([]) const chosenInstallOptions = ref([])
const detectJavaModal = ref(null) const detectJavaModal = ref(null)
const currentSelected = ref({}) const currentSelected = ref({})
defineExpose({ defineExpose({
show: async (version, currentSelectedJava) => { show: async (version, currentSelectedJava) => {
chosenInstallOptions.value = await find_filtered_jres(version).catch(handleError) chosenInstallOptions.value = await find_filtered_jres(version).catch(handleError)
currentSelected.value = currentSelectedJava currentSelected.value = currentSelectedJava
if (!currentSelected.value) { if (!currentSelected.value) {
currentSelected.value = { path: '', version: '' } currentSelected.value = { path: '', version: '' }
} }
detectJavaModal.value.show() detectJavaModal.value.show()
}, },
}) })
const emit = defineEmits(['submit']) const emit = defineEmits(['submit'])
function setJavaInstall(javaInstall) { function setJavaInstall(javaInstall) {
emit('submit', javaInstall) emit('submit', javaInstall)
detectJavaModal.value.hide() detectJavaModal.value.hide()
trackEvent('JavaAutoDetect', { trackEvent('JavaAutoDetect', {
path: javaInstall.path, path: javaInstall.path,
version: javaInstall.version, version: javaInstall.version,
}) })
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.auto-detect-modal { .auto-detect-modal {
.table { .table {
.table-row { .table-row {
grid-template-columns: 1fr 4fr min-content; grid-template-columns: 1fr 4fr min-content;
} }
span { span {
display: inherit; display: inherit;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
padding: 0.5rem; padding: 0.5rem;
} }
} }
.manage { .manage {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }
</style> </style>

View File

@@ -1,100 +1,102 @@
<template> <template>
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" /> <JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div class="toggle-setting" :class="{ compact }"> <div class="toggle-setting" :class="{ compact }">
<input <input
autocomplete="off" autocomplete="off"
:disabled="props.disabled" :disabled="props.disabled"
:value="props.modelValue ? props.modelValue.path : ''" :value="props.modelValue ? props.modelValue.path : ''"
type="text" type="text"
class="installation-input" class="installation-input"
:placeholder="placeholder ?? '/path/to/java'" :placeholder="placeholder ?? '/path/to/java'"
@input=" @input="
(val) => { (val) => {
emit('update:modelValue', { emit('update:modelValue', {
...props.modelValue, ...props.modelValue,
path: val.target.value, path: val.target.value,
}) })
} }
" "
/> />
<span class="installation-buttons"> <span class="installation-buttons">
<Button <Button
v-if="props.version" v-if="props.version"
:disabled="props.disabled || installingJava" :disabled="props.disabled || installingJava"
@click="reinstallJava" @click="reinstallJava"
> >
<DownloadIcon /> <DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }} {{ installingJava ? 'Installing...' : 'Install recommended' }}
</Button> </Button>
<Button :disabled="props.disabled" @click="autoDetect"> <Button :disabled="props.disabled" @click="autoDetect">
<SearchIcon /> <SearchIcon />
Detect Detect
</Button> </Button>
<Button :disabled="props.disabled" @click="handleJavaFileInput()"> <Button :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon /> <FolderSearchIcon />
Browse Browse
</Button> </Button>
<Button v-if="testingJava" disabled> Testing... </Button> <Button v-if="testingJava" disabled> Testing... </Button>
<Button v-else-if="testingJavaSuccess === true"> <Button v-else-if="testingJavaSuccess === true">
<CheckIcon class="test-success" /> <CheckIcon class="test-success" />
Success Success
</Button> </Button>
<Button v-else-if="testingJavaSuccess === false"> <Button v-else-if="testingJavaSuccess === false">
<XIcon class="test-fail" /> <XIcon class="test-fail" />
Failed Failed
</Button> </Button>
<Button v-else :disabled="props.disabled" @click="testJava"> <Button v-else :disabled="props.disabled" @click="testJava">
<PlayIcon /> <PlayIcon />
Test Test
</Button> </Button>
</span> </span>
</div> </div>
</template> </template>
<script setup> <script setup>
import { import {
SearchIcon, CheckIcon,
PlayIcon, DownloadIcon,
CheckIcon, FolderSearchIcon,
XIcon, PlayIcon,
FolderSearchIcon, SearchIcon,
DownloadIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button } from '@modrinth/ui' import { Button, injectNotificationManager } from '@modrinth/ui'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import { ref } from 'vue'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue' import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
version: { version: {
type: Number, type: Number,
required: false, required: false,
default: null, default: null,
}, },
modelValue: { modelValue: {
type: Object, type: Object,
default: () => ({ default: () => ({
path: '', path: '',
version: '', version: '',
}), }),
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
placeholder: { placeholder: {
type: String, type: String,
required: false, required: false,
default: null, default: null,
}, },
compact: { compact: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -105,116 +107,115 @@ const testingJavaSuccess = ref(null)
const installingJava = ref(false) const installingJava = ref(false)
async function testJava() { async function testJava() {
testingJava.value = true testingJava.value = true
testingJavaSuccess.value = await test_jre( testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '', props.modelValue ? props.modelValue.path : '',
1, props.version,
props.version, )
) testingJava.value = false
testingJava.value = false
trackEvent('JavaTest', { trackEvent('JavaTest', {
path: props.modelValue ? props.modelValue.path : '', path: props.modelValue ? props.modelValue.path : '',
success: testingJavaSuccess.value, success: testingJavaSuccess.value,
}) })
setTimeout(() => { setTimeout(() => {
testingJavaSuccess.value = null testingJavaSuccess.value = null
}, 2000) }, 2000)
} }
async function handleJavaFileInput() { async function handleJavaFileInput() {
const filePath = await open() const filePath = await open()
if (filePath) { if (filePath) {
let result = await get_jre(filePath.path ?? filePath).catch(handleError) let result = await get_jre(filePath.path ?? filePath).catch(handleError)
if (!result) { if (!result) {
result = { result = {
path: filePath.path ?? filePath, path: filePath.path ?? filePath,
version: props.version.toString(), version: props.version.toString(),
architecture: 'x86', architecture: 'x86',
} }
} }
trackEvent('JavaManualSelect', { trackEvent('JavaManualSelect', {
version: props.version, version: props.version,
}) })
emit('update:modelValue', result) emit('update:modelValue', result)
} }
} }
const detectJavaModal = ref(null) const detectJavaModal = ref(null)
async function autoDetect() { async function autoDetect() {
if (!props.compact) { if (!props.compact) {
detectJavaModal.value.show(props.version, props.modelValue) detectJavaModal.value.show(props.version, props.modelValue)
} else { } else {
const versions = await find_filtered_jres(props.version).catch(handleError) const versions = await find_filtered_jres(props.version).catch(handleError)
if (versions.length > 0) { if (versions.length > 0) {
emit('update:modelValue', versions[0]) emit('update:modelValue', versions[0])
} }
} }
} }
async function reinstallJava() { async function reinstallJava() {
installingJava.value = true installingJava.value = true
const path = await auto_install_java(props.version).catch(handleError) const path = await auto_install_java(props.version).catch(handleError)
let result = await get_jre(path) let result = await get_jre(path)
if (!result) { if (!result) {
result = { result = {
path: path, path: path,
version: props.version.toString(), version: props.version.toString(),
architecture: 'x86', architecture: 'x86',
} }
} }
trackEvent('JavaReInstall', { trackEvent('JavaReInstall', {
path: path, path: path,
version: props.version, version: props.version,
}) })
emit('update:modelValue', result) emit('update:modelValue', result)
installingJava.value = false installingJava.value = false
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.installation-input { .installation-input {
width: 100% !important; width: 100% !important;
flex-grow: 1; flex-grow: 1;
} }
.toggle-setting { .toggle-setting {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
&.compact { &.compact {
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
.installation-buttons { .installation-buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin: 0; margin: 0;
.btn { .btn {
width: max-content; width: max-content;
} }
} }
.test-success { .test-success {
color: var(--color-green); color: var(--color-green);
} }
.test-fail { .test-fail {
color: var(--color-red); color: var(--color-red);
} }
</style> </style>

View File

@@ -1,33 +1,34 @@
<script setup> <script setup>
import { CheckIcon } from '@modrinth/assets' import { CheckIcon } from '@modrinth/assets'
import { Button, Badge } from '@modrinth/ui' import { Badge, Button } from '@modrinth/ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { update_managed_modrinth_version } from '@/helpers/profile'
import { releaseColor } from '@/helpers/utils'
import { SwapIcon } from '@/assets/icons/index.js' import { SwapIcon } from '@/assets/icons/index.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { update_managed_modrinth_version } from '@/helpers/profile'
import { releaseColor } from '@/helpers/utils'
const props = defineProps({ const props = defineProps({
versions: { versions: {
type: Array, type: Array,
required: true, required: true,
}, },
instance: { instance: {
type: Object, type: Object,
default: null, default: null,
}, },
}) })
defineExpose({ defineExpose({
show: () => { show: () => {
modpackVersionModal.value.show() modpackVersionModal.value.show()
}, },
}) })
const emit = defineEmits(['finish-install']) const emit = defineEmits(['finish-install'])
const filteredVersions = computed(() => { const filteredVersions = computed(() => {
return props.versions return props.versions
}) })
const modpackVersionModal = ref(null) const modpackVersionModal = ref(null)
@@ -36,160 +37,160 @@ const installing = computed(() => props.instance.install_stage !== 'installed')
const inProgress = ref(false) const inProgress = ref(false)
const switchVersion = async (versionId) => { const switchVersion = async (versionId) => {
modpackVersionModal.value.hide() modpackVersionModal.value.hide()
inProgress.value = true inProgress.value = true
await update_managed_modrinth_version(props.instance.path, versionId) await update_managed_modrinth_version(props.instance.path, versionId)
inProgress.value = false inProgress.value = false
emit('finish-install') emit('finish-install')
} }
const onHide = () => { const onHide = () => {
if (!inProgress.value) { if (!inProgress.value) {
emit('finish-install') emit('finish-install')
} }
} }
</script> </script>
<template> <template>
<ModalWrapper <ModalWrapper
ref="modpackVersionModal" ref="modpackVersionModal"
class="modpack-version-modal" class="modpack-version-modal"
header="Change modpack version" header="Change modpack version"
:on-hide="onHide" :on-hide="onHide"
> >
<div class="modal-body"> <div class="modal-body">
<div v-if="instance.linked_data" class="mod-card"> <div v-if="instance.linked_data" class="mod-card">
<div class="table"> <div class="table">
<div class="table-row with-columns table-head"> <div class="table-row with-columns table-head">
<div class="table-cell table-text download-cell" /> <div class="table-cell table-text download-cell" />
<div class="name-cell table-cell table-text">Name</div> <div class="name-cell table-cell table-text">Name</div>
<div class="table-cell table-text">Supports</div> <div class="table-cell table-text">Supports</div>
</div> </div>
<div class="scrollable"> <div class="scrollable">
<div <div
v-for="version in filteredVersions" v-for="version in filteredVersions"
:key="version.id" :key="version.id"
class="table-row with-columns selectable" class="table-row with-columns selectable"
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)" @click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
> >
<div class="table-cell table-text"> <div class="table-cell table-text">
<Button <Button
:color="version.id === installedVersion ? '' : 'primary'" :color="version.id === installedVersion ? '' : 'primary'"
icon-only icon-only
:disabled="inProgress || installing || version.id === installedVersion" :disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)" @click.stop="() => switchVersion(version.id)"
> >
<SwapIcon v-if="version.id !== installedVersion" /> <SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else /> <CheckIcon v-else />
</Button> </Button>
</div> </div>
<div class="name-cell table-cell table-text"> <div class="name-cell table-cell table-text">
<div class="version-link"> <div class="version-link">
{{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }} {{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }}
<div class="version-badge"> <div class="version-badge">
<div class="channel-indicator"> <div class="channel-indicator">
<Badge <Badge
:color="releaseColor(version.version_type)" :color="releaseColor(version.version_type)"
:type=" :type="
version.version_type.charAt(0).toUpperCase() + version.version_type.charAt(0).toUpperCase() +
version.version_type.slice(1) version.version_type.slice(1)
" "
/> />
</div> </div>
<div> <div>
{{ version.version_number }} {{ version.version_number }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="table-cell table-text stacked-text"> <div class="table-cell table-text stacked-text">
<span> <span>
{{ {{
version.loaders version.loaders
.map((str) => str.charAt(0).toUpperCase() + str.slice(1)) .map((str) => str.charAt(0).toUpperCase() + str.slice(1))
.join(', ') .join(', ')
}} }}
</span> </span>
<span> <span>
{{ version.game_versions.join(', ') }} {{ version.game_versions.join(', ') }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.filter-header { .filter-header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.with-columns { .with-columns {
grid-template-columns: min-content 1fr 1fr; grid-template-columns: min-content 1fr 1fr;
} }
.scrollable { .scrollable {
overflow-y: auto; overflow-y: auto;
max-height: 25rem; max-height: 25rem;
} }
.card-row { .card-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
} }
.mod-card { .mod-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
overflow: hidden; overflow: hidden;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.version-link { .version-link {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
.version-badge { .version-badge {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.channel-indicator { .channel-indicator {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
} }
} }
.stacked-text { .stacked-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
align-items: flex-start; align-items: flex-start;
} }
.download-cell { .download-cell {
width: 4rem; width: 4rem;
padding: 1rem; padding: 1rem;
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-md); gap: var(--gap-md);
} }
.table { .table {
border: 1px solid var(--color-bg); border: 1px solid var(--color-bg);
} }
</style> </style>

View File

@@ -1,24 +1,24 @@
<template> <template>
<RouterLink <RouterLink
v-if="typeof to === 'string'" v-if="typeof to === 'string'"
:to="to" :to="to"
v-bind="$attrs" v-bind="$attrs"
:class="{ :class="{
'router-link-active': isPrimary && isPrimary(route), 'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route), 'subpage-active': isSubpage && isSubpage(route),
}" }"
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" 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"
> >
<slot /> <slot />
</RouterLink> </RouterLink>
<button <button
v-else v-else
v-bind="$attrs" v-bind="$attrs"
class="button-animation border-none text-primary cursor-pointer w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast" class="button-animation border-none text-primary cursor-pointer w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
@click="to" @click="to"
> >
<slot /> <slot />
</button> </button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -30,30 +30,30 @@ const route = useRoute()
type RouteFunction = (route: RouteLocationNormalizedLoaded) => boolean type RouteFunction = (route: RouteLocationNormalizedLoaded) => boolean
defineProps<{ defineProps<{
to: (() => void) | string to: (() => void) | string
isPrimary?: RouteFunction isPrimary?: RouteFunction
isSubpage?: RouteFunction isSubpage?: RouteFunction
highlightOverride?: boolean highlightOverride?: boolean
}>() }>()
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.router-link-active, .router-link-active,
.subpage-active { .subpage-active {
svg { svg {
filter: drop-shadow(0 0 0.5rem black); filter: drop-shadow(0 0 0.5rem black);
} }
} }
.router-link-active { .router-link-active {
@apply text-[--color-button-text-selected] bg-[--color-button-bg-selected]; @apply text-[--color-button-text-selected] bg-[--color-button-bg-selected];
} }
.subpage-active { .subpage-active {
@apply text-contrast bg-button-bg; @apply text-contrast bg-button-bg;
} }
</style> </style>

View File

@@ -1,50 +1,50 @@
<template> <template>
<nav <nav
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold" class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
> >
<RouterLink <RouterLink
v-for="(link, index) in filteredLinks" v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown" v-show="link.shown === undefined ? true : link.shown"
:key="index" :key="index"
ref="tabLinkElements" ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href" :to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
:class="`button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full ${activeIndex === index && !subpageSelected ? 'text-button-textSelected' : activeIndex === index && subpageSelected ? 'text-contrast' : 'text-primary'}`" :class="`button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full ${activeIndex === index && !subpageSelected ? 'text-button-textSelected' : activeIndex === index && subpageSelected ? 'text-contrast' : 'text-primary'}`"
> >
<component :is="link.icon" v-if="link.icon" class="size-5" /> <component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span> <span class="text-nowrap">{{ link.label }}</span>
</RouterLink> </RouterLink>
<div <div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`" :class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`"
:style="{ :style="{
left: sliderLeftPx, left: sliderLeftPx,
top: sliderTopPx, top: sliderTopPx,
right: sliderRightPx, right: sliderRightPx,
bottom: sliderBottomPx, bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1, opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}" }"
aria-hidden="true" aria-hidden="true"
></div> ></div>
</nav> </nav>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router' import type { RouteLocationRaw } from 'vue-router'
import { useRoute, RouterLink } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
interface Tab { interface Tab {
label: string label: string
href: string | RouteLocationRaw href: string | RouteLocationRaw
shown?: boolean shown?: boolean
icon?: unknown icon?: unknown
subpages?: string[] subpages?: string[]
} }
const props = defineProps<{ const props = defineProps<{
links: Tab[] links: Tab[]
query?: string query?: string
}>() }>()
const sliderLeft = ref(4) const sliderLeft = ref(4)
@@ -56,7 +56,7 @@ const oldIndex = ref(-1)
const subpageSelected = ref(false) const subpageSelected = ref(false)
const filteredLinks = computed(() => const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)), props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
) )
const sliderLeftPx = computed(() => `${sliderLeft.value}px`) const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`) const sliderTopPx = computed(() => `${sliderTop.value}px`)
@@ -64,97 +64,97 @@ const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`) const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
function pickLink() { function pickLink() {
let index = -1 let index = -1
subpageSelected.value = false subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) { for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i] const link = filteredLinks.value[i]
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) { if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
index = i index = i
break break
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) { } else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
index = i index = i
subpageSelected.value = true subpageSelected.value = true
break break
} }
} }
activeIndex.value = index activeIndex.value = index
if (activeIndex.value !== -1) { if (activeIndex.value !== -1) {
startAnimation() startAnimation()
} else { } else {
oldIndex.value = -1 oldIndex.value = -1
sliderLeft.value = 0 sliderLeft.value = 0
sliderRight.value = 0 sliderRight.value = 0
} }
} }
const tabLinkElements = ref() const tabLinkElements = ref()
function startAnimation() { function startAnimation() {
const el = tabLinkElements.value[activeIndex.value].$el const el = tabLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return if (!el || !el.offsetParent) return
const newValues = { const newValues = {
left: el.offsetLeft, left: el.offsetLeft,
top: el.offsetTop, top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth, right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight, bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
} }
if (sliderLeft.value === 4 && sliderRight.value === 4) { if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left sliderLeft.value = newValues.left
sliderRight.value = newValues.right sliderRight.value = newValues.right
sliderTop.value = newValues.top sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom sliderBottom.value = newValues.bottom
} else { } else {
const delay = 200 const delay = 200
if (newValues.left < sliderLeft.value) { if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left sliderLeft.value = newValues.left
setTimeout(() => { setTimeout(() => {
sliderRight.value = newValues.right sliderRight.value = newValues.right
}, delay) }, delay)
} else { } else {
sliderRight.value = newValues.right sliderRight.value = newValues.right
setTimeout(() => { setTimeout(() => {
sliderLeft.value = newValues.left sliderLeft.value = newValues.left
}, delay) }, delay)
} }
if (newValues.top < sliderTop.value) { if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top sliderTop.value = newValues.top
setTimeout(() => { setTimeout(() => {
sliderBottom.value = newValues.bottom sliderBottom.value = newValues.bottom
}, delay) }, delay)
} else { } else {
sliderBottom.value = newValues.bottom sliderBottom.value = newValues.bottom
setTimeout(() => { setTimeout(() => {
sliderTop.value = newValues.top sliderTop.value = newValues.top
}, delay) }, delay)
} }
} }
} }
onMounted(() => { onMounted(() => {
window.addEventListener('resize', pickLink) window.addEventListener('resize', pickLink)
pickLink() pickLink()
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', pickLink) window.removeEventListener('resize', pickLink)
}) })
watch(route, () => { watch(route, () => {
pickLink() pickLink()
}) })
</script> </script>
<style scoped> <style scoped>
.navtabs-transition { .navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */ /* Delay on opacity is to hide any jankiness as the page loads */
transition: transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s, all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms; opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
} }
</style> </style>

View File

@@ -1,33 +1,33 @@
<template> <template>
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div> <div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({ defineProps({
progress: { progress: {
type: Number, type: Number,
required: true, required: true,
validator(value) { validator(value) {
return value >= 0 && value <= 100 return value >= 0 && value <= 100
}, },
}, },
}) })
</script> </script>
<style scoped> <style scoped>
.progress-bar { .progress-bar {
width: 100%; width: 100%;
height: 0.5rem; height: 0.5rem;
background-color: var(--color-button-bg); background-color: var(--color-button-bg);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
} }
.progress-bar__fill { .progress-bar__fill {
height: 100%; height: 100%;
background-color: var(--color-brand); background-color: var(--color-brand);
transition: width 0.3s; transition: width 0.3s;
} }
</style> </style>

View File

@@ -1,10 +1,10 @@
<script setup> <script setup>
import { Avatar, TagItem } from '@modrinth/ui'
import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets' import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
import { formatNumber, formatCategory } from '@modrinth/utils' import { Avatar, TagItem } from '@modrinth/ui'
import { computed } from 'vue' import { formatCategory, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
@@ -12,110 +12,107 @@ dayjs.extend(relativeTime)
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps({
project: { project: {
type: Object, type: Object,
default() { default() {
return {} return {}
}, },
}, },
}) })
const featuredCategory = computed(() => { const featuredCategory = computed(() => {
if (props.project.categories.includes('optimization')) { if (props.project.display_categories.includes('optimization')) {
return 'optimization' return 'optimization'
} }
if (props.project.categories.length > 0) { return props.project.display_categories[0] ?? props.project.categories[0]
return props.project.categories[0]
}
return undefined
}) })
const toColor = computed(() => { const toColor = computed(() => {
let color = props.project.color let color = props.project.color
color >>>= 0 color >>>= 0
const b = color & 0xff const b = color & 0xff
const g = (color >>> 8) & 0xff const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff const r = (color >>> 16) & 0xff
return 'rgba(' + [r, g, b, 1].join(',') + ')' return 'rgba(' + [r, g, b, 1].join(',') + ')'
}) })
const toTransparent = computed(() => { const toTransparent = computed(() => {
let color = props.project.color let color = props.project.color
color >>>= 0 color >>>= 0
const b = color & 0xff const b = color & 0xff
const g = (color >>> 8) & 0xff const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff const r = (color >>> 16) & 0xff
return ( return (
'linear-gradient(rgba(' + 'linear-gradient(rgba(' +
[r, g, b, 0.03].join(',') + [r, g, b, 0.03].join(',') +
'), 65%, rgba(' + '), 65%, rgba(' +
[r, g, b, 0.3].join(',') + [r, g, b, 0.3].join(',') +
'))' '))'
) )
}) })
</script> </script>
<template> <template>
<div <div
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all" class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
@click="router.push(`/project/${project.slug}`)" @click="router.push(`/project/${project.slug}`)"
> >
<div <div
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat" class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
:style="{ :style="{
'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor, 'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor,
'background-image': `url(${ 'background-image': `url(${
project.featured_gallery ?? project.featured_gallery ??
project.gallery[0] ?? project.gallery[0] ??
'https://launcher-files.modrinth.com/assets/maze-bg.png' 'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`, })`,
}" }"
> >
<div <div
class="badges-wrapper" class="badges-wrapper"
:class="{ :class="{
'no-image': !project.featured_gallery && !project.gallery[0], 'no-image': !project.featured_gallery && !project.gallery[0],
}" }"
:style="{ :style="{
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null, background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
}" }"
></div> ></div>
</div> </div>
<div class="flex flex-col justify-center gap-2 px-4 py-3"> <div class="flex flex-col justify-center gap-2 px-4 py-3">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<Avatar size="48px" :src="project.icon_url" /> <Avatar size="48px" :src="project.icon_url" />
<div class="h-full flex items-center font-bold text-contrast leading-normal"> <div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ project.title }}</span> <span class="line-clamp-2">{{ project.title }}</span>
</div> </div>
</div> </div>
<p class="m-0 text-sm font-medium line-clamp-3 leading-tight h-[3.25rem]"> <p class="m-0 text-sm font-medium line-clamp-3 leading-tight h-[3.25rem]">
{{ project.description }} {{ project.description }}
</p> </p>
<div class="flex items-center gap-2 text-sm text-secondary font-semibold mt-auto"> <div class="flex items-center gap-2 text-sm text-secondary font-semibold mt-auto">
<div <div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border" class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
> >
<DownloadIcon /> <DownloadIcon />
{{ formatNumber(project.downloads) }} {{ formatNumber(project.downloads) }}
</div> </div>
<div <div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border" class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
> >
<HeartIcon /> <HeartIcon />
{{ formatNumber(project.follows) }} {{ formatNumber(project.follows) }}
</div> </div>
<div class="flex items-center gap-1 pr-2"> <div class="flex items-center gap-1 pr-2">
<TagIcon /> <TagIcon />
<TagItem> <TagItem>
{{ formatCategory(featuredCategory) }} {{ formatCategory(featuredCategory) }}
</TagItem> </TagItem>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -1,73 +1,75 @@
<script setup> <script setup>
import { list } from '@/helpers/profile' import { SpinnerIcon } from '@modrinth/assets'
import { handleError } from '@/store/notifications' import { Avatar, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue' import { onUnmounted, ref } from 'vue'
import { profile_listener } from '@/helpers/events.js'
import NavButton from '@/components/ui/NavButton.vue' import NavButton from '@/components/ui/NavButton.vue'
import { Avatar } from '@modrinth/ui' import { profile_listener } from '@/helpers/events.js'
import { convertFileSrc } from '@tauri-apps/api/core' import { list } from '@/helpers/profile'
import { SpinnerIcon } from '@modrinth/assets'
const { handleError } = injectNotificationManager()
const recentInstances = ref([]) const recentInstances = ref([])
const getInstances = async () => { const getInstances = async () => {
const profiles = await list().catch(handleError) const profiles = await list().catch(handleError)
recentInstances.value = profiles recentInstances.value = profiles
.sort((a, b) => { .sort((a, b) => {
const dateACreated = dayjs(a.created) const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0) const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
const dateBCreated = dayjs(b.created) const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0) const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
if (dateA.isSame(dateB)) { if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
} }
return dateB - dateA return dateB - dateA
}) })
.slice(0, 3) .slice(0, 3)
} }
await getInstances() await getInstances()
const unlistenProfile = await profile_listener(async (event) => { const unlistenProfile = await profile_listener(async (event) => {
if (event.event !== 'synced') { if (event.event !== 'synced') {
await getInstances() await getInstances()
} }
}) })
onUnmounted(() => { onUnmounted(() => {
unlistenProfile() unlistenProfile()
}) })
</script> </script>
<template> <template>
<NavButton <NavButton
v-for="instance in recentInstances" v-for="instance in recentInstances"
:key="instance.id" :key="instance.id"
v-tooltip.right="instance.name" v-tooltip.right="instance.name"
:to="`/instance/${encodeURIComponent(instance.path)}`" :to="`/instance/${encodeURIComponent(instance.path)}`"
class="relative" class="relative"
> >
<Avatar <Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px" size="28px"
:tint-by="instance.path" :tint-by="instance.path"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`" :class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/> />
<div <div
v-if="instance.install_stage !== 'installed'" v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10" class="absolute inset-0 flex items-center justify-center z-10"
> >
<SpinnerIcon class="animate-spin w-4 h-4" /> <SpinnerIcon class="animate-spin w-4 h-4" />
</div> </div>
</NavButton> </NavButton>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-button-bg"></div> <div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-button-bg"></div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -1,146 +1,119 @@
<template> <template>
<div class="action-groups"> <div class="action-groups">
<ButtonStyled v-if="currentLoadingBars.length > 0" color="brand" type="transparent" circular> <ButtonStyled v-if="currentLoadingBars.length > 0" color="brand" type="transparent" circular>
<button ref="infoButton" @click="toggleCard()"> <button ref="infoButton" @click="toggleCard()">
<DownloadIcon /> <DownloadIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<div v-if="offline" class="status"> <div v-if="offline" class="status">
<UnplugIcon /> <UnplugIcon />
<div class="running-text"> <div class="running-text">
<span> Offline </span> <span> Offline </span>
</div> </div>
</div> </div>
<div v-if="selectedProcess" class="status"> <div v-if="selectedProcess" class="status">
<span class="circle running" /> <span class="circle running" />
<div ref="profileButton" class="running-text"> <div ref="profileButton" class="running-text">
<router-link <router-link
class="text-primary" class="text-primary"
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`" :to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
> >
{{ selectedProcess.profile.name }} {{ selectedProcess.profile.name }}
</router-link> </router-link>
<div v-if="currentProcesses.length > 1" class="arrow button-base" :class="{ rotate: showProfiles }" <div
@click="toggleProfiles()"> v-if="currentProcesses.length > 1"
<DropdownIcon /> class="arrow button-base"
</div> :class="{ rotate: showProfiles }"
</div> @click="toggleProfiles()"
<Button v-tooltip="'Stop instance'" icon-only class="icon-button stop" @click="stop(selectedProcess)"> >
<StopCircleIcon /> <DropdownIcon />
</Button> </div>
<Button v-tooltip="'View logs'" icon-only class="icon-button" @click="goToTerminal()"> </div>
<TerminalSquareIcon /> <Button
</Button> v-tooltip="'Stop instance'"
</div> icon-only
<div v-else class="status"> class="icon-button stop"
<span class="circle stopped" /> @click="stop(selectedProcess)"
<span class="running-text"> No instances running </span> >
</div> <StopCircleIcon />
<div v-if="updateState"> </Button>
<a> <Button v-tooltip="'View logs'" icon-only class="icon-button" @click="goToTerminal()">
<Button class="download" :disabled="installState" @click="confirmUpdating(), getRemote(false, false)"> <TerminalSquareIcon />
<DownloadIcon /> </Button>
{{ </div>
installState <div v-else class="status">
? "Downloading new update..." <span class="circle stopped" />
: "Download new update" <span class="running-text"> No instances running </span>
}} </div>
</Button> </div>
</a> <transition name="download">
</div> <Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
<ModalWrapper ref="confirmUpdate" :has-to-type="false" header="Request to update the AstralRinth launcher"> <div v-for="loadingBar in currentLoadingBars" :key="loadingBar.id" class="info-text">
<div class="modal-body"> <h3 class="info-title">
<div class="markdown-body"> {{ loadingBar.title }}
<p>The new version of the AstralRinth launcher is available.</p> </h3>
<p>Your version is outdated. We recommend that you update to the latest version.</p> <ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<p><strong> Warning </strong></p> <div class="row">
<p> {{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
Before updating, make sure that you have saved all running instances and made a backup copy of the instances {{ loadingBar.message }}
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of </div>
your files, so you should always make copies of them and keep them in a safe place. </div>
</p> </Card>
</div> </transition>
<span>Source Git Astralium</span> <transition name="download">
<span>Version on remote server <p id="releaseData" class="cosmic inline-fix"></p></span> <Card
<span>Version on local device v-if="showProfiles === true && currentProcesses.length > 0"
<p class="cosmic inline-fix">v{{ version }}</p> ref="profiles"
</span> class="profile-card"
<div class="button-group push-right"> >
<Button class="download-modal" @click="confirmUpdate.hide()"> <Button
Decline</Button> v-for="process in currentProcesses"
<Button class="download-modal" @click="approveUpdate()"> :key="process.uuid"
Accept class="profile-button"
</Button> @click="selectProcess(process)"
</div> >
</div> <div class="text"><span class="circle running" /> {{ process.profile.name }}</div>
</ModalWrapper> <Button
</div> v-tooltip="'Stop instance'"
<transition name="download"> icon-only
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card"> class="icon-button stop"
<div v-for="loadingBar in currentLoadingBars" :key="loadingBar.id" class="info-text"> @click.stop="stop(process)"
<h3 class="info-title"> >
{{ loadingBar.title }} <StopCircleIcon />
</h3> </Button>
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" /> <Button
<div class="row"> v-tooltip="'View logs'"
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}% {{ loadingBar.message }} icon-only
</div> class="icon-button"
</div> @click.stop="goToTerminal(process.profile.path)"
</Card> >
</transition> <TerminalSquareIcon />
<transition name="download"> </Button>
<Card v-if="showProfiles === true && currentProcesses.length > 0" ref="profiles" class="profile-card"> </Button>
<Button v-for="process in currentProcesses" :key="process.uuid" class="profile-button" </Card>
@click="selectProcess(process)"> </transition>
<div class="text"><span class="circle running" /> {{ process.profile.name }}</div>
<Button v-tooltip="'Stop instance'" icon-only class="icon-button stop" @click.stop="stop(process)">
<StopCircleIcon />
</Button>
<Button v-tooltip="'View logs'" icon-only class="icon-button" @click.stop="goToTerminal(process.profile.path)">
<TerminalSquareIcon />
</Button>
</Button>
</Card>
</transition>
</template> </template>
<script setup> <script setup>
import { import {
DownloadIcon, DownloadIcon,
StopCircleIcon, DropdownIcon,
TerminalSquareIcon, StopCircleIcon,
DropdownIcon, TerminalSquareIcon,
UnplugIcon, UnplugIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button, ButtonStyled, Card } from '@modrinth/ui' import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { loading_listener, process_listener } from '@/helpers/events'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { progress_bars_list } from '@/helpers/state.js'
import ProgressBar from '@/components/ui/ProgressBar.vue' import ProgressBar from '@/components/ui/ProgressBar.vue'
import { handleError } from '@/store/notifications.js'
import { get_many } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { getVersion } from '@tauri-apps/api/app' import { loading_listener, process_listener } from '@/helpers/events'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { get_many } from '@/helpers/profile.js'
import { progress_bars_list } from '@/helpers/state.js'
const version = await getVersion() const { handleError } = injectNotificationManager()
import { installState, getRemote, updateState } from '@/helpers/update.js'
import ModalWrapper from './modal/ModalWrapper.vue'
const confirmUpdate = ref(null)
const confirmUpdating = async () => {
confirmUpdate.value.show()
}
const approveUpdate = async () => {
confirmUpdate.value.hide()
await getRemote(true, true)
}
await getRemote(true, false)
const router = useRouter() const router = useRouter()
const card = ref(null) const card = ref(null)
@@ -155,445 +128,347 @@ const currentProcesses = ref([])
const selectedProcess = ref() const selectedProcess = ref()
const refresh = async () => { const refresh = async () => {
const processes = await getRunningProcesses().catch(handleError) const processes = await getRunningProcesses().catch(handleError)
const profiles = await get_many(processes.map((x) => x.profile_path)).catch(handleError) const profiles = await get_many(processes.map((x) => x.profile_path)).catch(handleError)
currentProcesses.value = processes.map((x) => ({ currentProcesses.value = processes.map((x) => ({
profile: profiles.find((prof) => x.profile_path === prof.path), profile: profiles.find((prof) => x.profile_path === prof.path),
...x, ...x,
})) }))
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) { if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
selectedProcess.value = currentProcesses.value[0] selectedProcess.value = currentProcesses.value[0]
} }
} }
await refresh() await refresh()
const offline = ref(!navigator.onLine) const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => { window.addEventListener('offline', () => {
offline.value = true offline.value = true
}) })
window.addEventListener('online', () => { window.addEventListener('online', () => {
offline.value = false offline.value = false
}) })
const unlistenProcess = await process_listener(async () => { const unlistenProcess = await process_listener(async () => {
await refresh() await refresh()
}) })
const stop = async (process) => { const stop = async (process) => {
try { try {
await killProcess(process.uuid).catch(handleError) await killProcess(process.uuid).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
loader: process.profile.loader, loader: process.profile.loader,
game_version: process.profile.game_version, game_version: process.profile.game_version,
source: 'AppBar', source: 'AppBar',
}) })
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
await refresh() await refresh()
} }
const goToTerminal = (path) => { const goToTerminal = (path) => {
router.push(`/instance/${encodeURIComponent(path ?? selectedProcess.value.profile.path)}/logs`) router.push(`/instance/${encodeURIComponent(path ?? selectedProcess.value.profile.path)}/logs`)
} }
const currentLoadingBars = ref([]) const currentLoadingBars = ref([])
const refreshInfo = async () => { const refreshInfo = async () => {
const currentLoadingBarCount = currentLoadingBars.value.length const currentLoadingBarCount = currentLoadingBars.value.length
currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError)).map( currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError)).map(
(x) => { (x) => {
if (x.bar_type.type === 'java_download') { if (x.bar_type.type === 'java_download') {
x.title = 'Downloading Java ' + x.bar_type.version x.title = 'Downloading Java ' + x.bar_type.version
} }
if (x.bar_type.profile_path) { if (x.bar_type.profile_path) {
x.title = x.bar_type.profile_path x.title = x.bar_type.profile_path
} }
if (x.bar_type.pack_name) { if (x.bar_type.pack_name) {
x.title = x.bar_type.pack_name x.title = x.bar_type.pack_name
} }
return x return x
}, },
) )
currentLoadingBars.value.sort((a, b) => { currentLoadingBars.value.sort((a, b) => {
if (a.loading_bar_uuid < b.loading_bar_uuid) { if (a.loading_bar_uuid < b.loading_bar_uuid) {
return -1 return -1
} }
if (a.loading_bar_uuid > b.loading_bar_uuid) { if (a.loading_bar_uuid > b.loading_bar_uuid) {
return 1 return 1
} }
return 0 return 0
}) })
if (currentLoadingBars.value.length === 0) { if (currentLoadingBars.value.length === 0) {
showCard.value = false showCard.value = false
} else if (currentLoadingBarCount < currentLoadingBars.value.length) { } else if (currentLoadingBarCount < currentLoadingBars.value.length) {
showCard.value = true showCard.value = true
} }
} }
await refreshInfo() await refreshInfo()
const unlistenLoading = await loading_listener(async () => { const unlistenLoading = await loading_listener(async () => {
await refreshInfo() await refreshInfo()
}) })
const selectProcess = (process) => { const selectProcess = (process) => {
selectedProcess.value = process selectedProcess.value = process
showProfiles.value = false showProfiles.value = false
} }
const handleClickOutsideCard = (event) => { const handleClickOutsideCard = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY) const elements = document.elementsFromPoint(event.clientX, event.clientY)
if ( if (
card.value && card.value &&
card.value.$el !== event.target && card.value.$el !== event.target &&
!elements.includes(card.value.$el) && !elements.includes(card.value.$el) &&
infoButton.value && infoButton.value &&
!infoButton.value.contains(event.target) !infoButton.value.contains(event.target)
) { ) {
showCard.value = false showCard.value = false
} }
} }
const handleClickOutsideProfile = (event) => { const handleClickOutsideProfile = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY) const elements = document.elementsFromPoint(event.clientX, event.clientY)
if ( if (
profiles.value && profiles.value &&
profiles.value.$el !== event.target && profiles.value.$el !== event.target &&
!elements.includes(profiles.value.$el) && !elements.includes(profiles.value.$el) &&
!profileButton.value.contains(event.target) !profileButton.value.contains(event.target)
) { ) {
showProfiles.value = false showProfiles.value = false
} }
} }
const toggleCard = async () => { const toggleCard = async () => {
showCard.value = !showCard.value showCard.value = !showCard.value
showProfiles.value = false showProfiles.value = false
await refreshInfo() await refreshInfo()
} }
const toggleProfiles = async () => { const toggleProfiles = async () => {
if (currentProcesses.value.length === 1) return if (currentProcesses.value.length === 1) return
showProfiles.value = !showProfiles.value showProfiles.value = !showProfiles.value
showCard.value = false showCard.value = false
} }
onMounted(() => { onMounted(() => {
window.addEventListener('click', handleClickOutsideCard) window.addEventListener('click', handleClickOutsideCard)
window.addEventListener('click', handleClickOutsideProfile) window.addEventListener('click', handleClickOutsideProfile)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutsideCard) window.removeEventListener('click', handleClickOutsideCard)
window.removeEventListener('click', handleClickOutsideProfile) window.removeEventListener('click', handleClickOutsideProfile)
unlistenProcess() unlistenProcess()
unlistenLoading() unlistenLoading()
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.inline-fix {
display: inline-flex;
margin-top: -2rem;
margin-bottom: -2rem;
//margin-left: 0.3rem;
}
.cosmic {
color: #3e8cde;
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
}
.markdown-body {
:deep(table) {
width: auto;
}
:deep(hr),
:deep(h1),
:deep(h2) {
max-width: max(60rem, 90%);
}
:deep(ul),
:deep(ol) {
margin-left: 2rem;
}
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: var(--gap-lg);
text-align: left;
.button-group {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
strong {
color: var(--color-contrast);
}
}
.download {
color: #3e8cde;
border-radius: var(--radius-md);
border: 1px solid var(--color-button-bg);
// padding: var(--gap-sm) var(--gap-lg);
background-color: rgba(0, 0, 0, 0);
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.download:hover,
.download:focus,
.download:active {
color: #10fae5;
text-shadow: #26065e;
}
.download-modal {
color: #3e8cde;
padding: var(--gap-sm) var(--gap-lg);
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
}
.download-modal:hover,
.download-modal:focus,
.download-modal:active {
color: #10fae5;
text-shadow: #26065e;
}
.action-groups { .action-groups {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: var(--gap-md); gap: var(--gap-md);
} }
.arrow { .arrow {
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
display: flex; display: flex;
align-items: center; align-items: center;
&.rotate {
&.rotate { transform: rotate(180deg);
transform: rotate(180deg); }
}
} }
.status { .status {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
padding: var(--gap-sm) var(--gap-lg); padding: var(--gap-sm) var(--gap-lg);
} }
.running-text { .running-text {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--gap-xs); gap: var(--gap-xs);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
-webkit-user-select: none; -webkit-user-select: none; /* Safari */
/* Safari */ -ms-user-select: none; /* IE 10 and IE 11 */
-ms-user-select: none; user-select: none;
/* IE 10 and IE 11 */
user-select: none;
&.clickable:hover { &.clickable:hover {
cursor: pointer; cursor: pointer;
} }
} }
.circle { .circle {
width: 0.5rem; width: 0.5rem;
height: 0.5rem; height: 0.5rem;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
margin-right: 0.25rem; margin-right: 0.25rem;
&.running { &.running {
background-color: var(--color-brand); background-color: var(--color-brand);
} }
&.stopped { &.stopped {
background-color: var(--color-base); background-color: var(--color-base);
} }
} }
.icon-button { .icon-button {
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);
box-shadow: none; box-shadow: none;
width: 1.25rem !important; width: 1.25rem !important;
height: 1.25rem !important; height: 1.25rem !important;
svg { svg {
min-width: 1.25rem; min-width: 1.25rem;
} }
&.stop { &.stop {
color: var(--color-red); color: var(--color-red);
} }
} }
.info-card { .info-card {
position: absolute; position: absolute;
top: 3.5rem; top: 3.5rem;
right: 0.5rem; right: 0.5rem;
z-index: 9; z-index: 9;
width: 20rem; width: 20rem;
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
box-shadow: var(--shadow-raised); box-shadow: var(--shadow-raised);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
overflow: auto; overflow: auto;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
&.hidden { &.hidden {
transform: translateY(-100%); transform: translateY(-100%);
} }
} }
.loading-option { .loading-option {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin: 0; margin: 0;
padding: 0; padding: 0;
:hover { :hover {
background-color: var(--color-raised-bg-hover); background-color: var(--color-raised-bg-hover);
} }
} }
.loading-text { .loading-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 0; margin: 0;
padding: 0; padding: 0;
.row { .row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
} }
.loading-icon { .loading-icon {
width: 2.25rem; width: 2.25rem;
height: 2.25rem; height: 2.25rem;
display: block; display: block;
:deep(svg) { :deep(svg) {
left: 1rem; left: 1rem;
width: 2.25rem; width: 2.25rem;
height: 2.25rem; height: 2.25rem;
} }
} }
.download-enter-active, .download-enter-active,
.download-leave-active { .download-leave-active {
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.download-enter-from, .download-enter-from,
.download-leave-to { .download-leave-to {
opacity: 0; opacity: 0;
} }
.progress-bar { .progress-bar {
width: 100%; width: 100%;
} }
.info-text { .info-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.info-title { .info-title {
margin: 0; margin: 0;
} }
.profile-button { .profile-button {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: var(--gap-sm); gap: var(--gap-sm);
width: 100%; width: 100%;
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
box-shadow: none; box-shadow: none;
.text { .text {
margin-right: auto; margin-right: auto;
} }
} }
.profile-card { .profile-card {
position: absolute; position: absolute;
top: 3.5rem; top: 3.5rem;
right: 0.5rem; right: 0.5rem;
z-index: 9; z-index: 9;
background-color: var(--color-raised-bg); background-color: var(--color-raised-bg);
box-shadow: var(--shadow-raised); box-shadow: var(--shadow-raised);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto; overflow: auto;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
border: 1px solid var(--color-button-bg); border: 1px solid var(--color-button-bg);
padding: var(--gap-md); padding: var(--gap-md);
&.hidden { &.hidden {
transform: translateY(-100%); transform: translateY(-100%);
} }
} }
.link { .link {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: var(--gap-sm); gap: var(--gap-sm);
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
text-decoration: none; text-decoration: none;
} }
</style> </style>

View File

@@ -1,159 +1,162 @@
<template> <template>
<div <div
class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all" class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all"
@click=" @click="
() => { () => {
emit('open') emit('open')
$router.push({ $router.push({
path: `/project/${project.project_id ?? project.id}`, path: `/project/${project.project_id ?? project.id}`,
query: { i: props.instance ? props.instance.path : undefined }, query: { i: props.instance ? props.instance.path : undefined },
}) })
} }
" "
> >
<div class="icon w-[96px] h-[96px] relative"> <div class="icon w-[96px] h-[96px] relative">
<Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" /> <Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" />
</div> </div>
<div class="flex flex-col gap-2 overflow-hidden"> <div class="flex flex-col gap-2 overflow-hidden">
<div class="gap-2 overflow-hidden no-wrap text-ellipsis"> <div class="gap-2 overflow-hidden no-wrap text-ellipsis">
<span class="text-lg font-extrabold text-contrast m-0 leading-none"> <span class="text-lg font-extrabold text-contrast m-0 leading-none">
{{ project.title }} {{ project.title }}
</span> </span>
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span> <span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
</div> </div>
<div class="m-0 line-clamp-2"> <div class="m-0 line-clamp-2">
{{ project.description }} {{ project.description }}
</div> </div>
<div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap"> <div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" /> <TagsIcon class="h-4 w-4 shrink-0" />
<div <div
v-if="project.project_type === 'mod' || project.project_type === 'modpack'" v-if="project.project_type === 'mod' || project.project_type === 'modpack'"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full" class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
> >
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'"> <template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
Client or server Client or server
</template> </template>
<template <template
v-else-if=" v-else-if="
(project.client_side === 'optional' || project.client_side === 'required') && (project.client_side === 'optional' || project.client_side === 'required') &&
(project.server_side === 'optional' || project.server_side === 'unsupported') (project.server_side === 'optional' || project.server_side === 'unsupported')
" "
> >
Client Client
</template> </template>
<template <template
v-else-if=" v-else-if="
(project.server_side === 'optional' || project.server_side === 'required') && (project.server_side === 'optional' || project.server_side === 'required') &&
(project.client_side === 'optional' || project.client_side === 'unsupported') (project.client_side === 'optional' || project.client_side === 'unsupported')
" "
> >
Server Server
</template> </template>
<template <template
v-else-if=" v-else-if="
project.client_side === 'unsupported' && project.server_side === 'unsupported' project.client_side === 'unsupported' && project.server_side === 'unsupported'
" "
> >
Unsupported Unsupported
</template> </template>
<template <template
v-else-if="project.client_side === 'required' && project.server_side === 'required'" v-else-if="project.client_side === 'required' && project.server_side === 'required'"
> >
Client and server Client and server
</template> </template>
</div> </div>
<div <div
v-for="tag in categories" v-for="tag in categories"
:key="tag" :key="tag"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full" class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
> >
{{ formatCategory(tag.name) }} {{ formatCategory(tag.name) }}
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto"> <div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<DownloadIcon class="shrink-0" /> <DownloadIcon class="shrink-0" />
<span> <span>
{{ formatNumber(project.downloads) }} {{ formatNumber(project.downloads) }}
<span class="text-secondary">downloads</span> <span class="text-secondary">downloads</span>
</span> </span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<HeartIcon class="shrink-0" /> <HeartIcon class="shrink-0" />
<span> <span>
{{ formatNumber(project.follows ?? project.followers) }} {{ formatNumber(project.follows ?? project.followers) }}
<span class="text-secondary">followers</span> <span class="text-secondary">followers</span>
</span> </span>
</div> </div>
<div class="mt-auto relative"> <div class="mt-auto relative">
<div class="absolute bottom-0 right-0 w-fit"> <div class="absolute bottom-0 right-0 w-fit">
<ButtonStyled color="brand" type="outlined"> <ButtonStyled color="brand" type="outlined">
<button <button
:disabled="installed || installing" :disabled="installed || installing"
class="shrink-0 no-wrap" class="shrink-0 no-wrap"
@click.stop="install()" @click.stop="install()"
> >
<template v-if="!installed"> <template v-if="!installed">
<DownloadIcon v-if="modpack || instance" /> <DownloadIcon v-if="modpack || instance" />
<PlusIcon v-else /> <PlusIcon v-else />
</template> </template>
<CheckIcon v-else /> <CheckIcon v-else />
{{ {{
installing installing
? 'Installing' ? 'Installing'
: installed : installed
? 'Installed' ? 'Installed'
: modpack || instance : modpack || instance
? 'Install' ? 'Install'
: 'Add to an instance' : 'Add to an instance'
}} }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { TagsIcon, DownloadIcon, HeartIcon, PlusIcon, CheckIcon } from '@modrinth/assets' import { CheckIcon, DownloadIcon, HeartIcon, PlusIcon, TagsIcon } from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui' import { Avatar, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { formatNumber, formatCategory } from '@modrinth/utils' import { formatCategory, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue' import { computed, ref } from 'vue'
import { install as installVersion } from '@/store/install.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { install as installVersion } from '@/store/install.js'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps({
backgroundImage: { backgroundImage: {
type: String, type: String,
default: null, default: null,
}, },
project: { project: {
type: Object, type: Object,
required: true, required: true,
}, },
categories: { categories: {
type: Array, type: Array,
required: true, required: true,
}, },
instance: { instance: {
type: Object, type: Object,
default: null, default: null,
}, },
featured: { featured: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
installed: { installed: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}) })
const emit = defineEmits(['open', 'install']) const emit = defineEmits(['open', 'install'])
@@ -161,20 +164,20 @@ const emit = defineEmits(['open', 'install'])
const installing = ref(false) const installing = ref(false)
async function install() { async function install() {
installing.value = true installing.value = true
await installVersion( await installVersion(
props.project.project_id ?? props.project.id, props.project.project_id ?? props.project.id,
null, null,
props.instance ? props.instance.path : null, props.instance ? props.instance.path : null,
'SearchCard', 'SearchCard',
() => { () => {
installing.value = false installing.value = false
emit('install', props.project.project_id ?? props.project.id) emit('install', props.project.project_id ?? props.project.id)
}, },
(profile) => { (profile) => {
router.push(`/instance/${profile}`) router.push(`/instance/${profile}`)
}, },
) ).catch(handleError)
} }
const modpack = computed(() => props.project.project_type === 'modpack') const modpack = computed(() => props.project.project_type === 'modpack')

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,14 @@
<script setup> <script setup>
import { Button } from '@modrinth/ui' import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_categories } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
import { get_version, get_project } from '@/helpers/cache.js'
import { install as installVersion } from '@/store/install.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project, get_version } from '@/helpers/cache.js'
import { get_categories } from '@/helpers/tags.js'
import { install as installVersion } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const confirmModal = ref(null) const confirmModal = ref(null)
const project = ref(null) const project = ref(null)
@@ -15,86 +17,93 @@ const categories = ref(null)
const installing = ref(false) const installing = ref(false)
defineExpose({ defineExpose({
async show(event) { async show(event) {
if (event.event === 'InstallVersion') { if (event.event === 'InstallVersion') {
version.value = await get_version(event.id, 'must_revalidate').catch(handleError) version.value = await get_version(event.id, 'must_revalidate').catch(handleError)
project.value = await get_project(version.value.project_id, 'must_revalidate').catch( project.value = await get_project(version.value.project_id, 'must_revalidate').catch(
handleError, handleError,
) )
} else { } else {
project.value = await get_project(event.id, 'must_revalidate').catch(handleError) project.value = await get_project(event.id, 'must_revalidate').catch(handleError)
version.value = await get_version( version.value = await get_version(
project.value.versions[project.value.versions.length - 1], project.value.versions[project.value.versions.length - 1],
'must_revalidate', 'must_revalidate',
).catch(handleError) ).catch(handleError)
} }
categories.value = (await get_categories().catch(handleError)).filter( categories.value = (await get_categories().catch(handleError)).filter(
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod', (cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
) )
confirmModal.value.show() confirmModal.value.show()
}, },
}) })
async function install() { async function install() {
confirmModal.value.hide() confirmModal.value.hide()
await installVersion(project.value.id, version.value.id, null, 'URLConfirmModal') await installVersion(
project.value.id,
version.value.id,
null,
'URLConfirmModal',
() => {},
() => {},
).catch(handleError)
} }
</script> </script>
<template> <template>
<ModalWrapper ref="confirmModal" :header="`Install ${project?.title}`"> <ModalWrapper ref="confirmModal" :header="`Install ${project?.title}`">
<div class="modal-body"> <div class="modal-body">
<SearchCard <SearchCard
:project="project" :project="project"
class="project-card" class="project-card"
:categories="categories" :categories="categories"
@open="confirmModal.hide()" @open="confirmModal.hide()"
/> />
<div class="button-row"> <div class="button-row">
<div class="markdown-body"> <div class="markdown-body">
<p> <p>
Installing <code>{{ version.id }}</code> from Modrinth Installing <code>{{ version.id }}</code> from Modrinth
</p> </p>
</div> </div>
<div class="button-group"> <div class="button-group">
<Button :loading="installing" color="primary" @click="install">Install</Button> <Button :loading="installing" color="primary" @click="install">Install</Button>
</div> </div>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--gap-md); gap: var(--gap-md);
} }
.button-row { .button-row {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--gap-md); gap: var(--gap-md);
} }
.button-group { .button-group {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--gap-sm); gap: var(--gap-sm);
} }
.project-card { .project-card {
background-color: var(--color-bg); background-color: var(--color-bg);
width: 100%; width: 100%;
:deep(.badge) { :deep(.badge) {
border: 1px solid var(--color-raised-bg); border: 1px solid var(--color-raised-bg);
background-color: var(--color-accent-contrast); background-color: var(--color-accent-contrast);
} }
} }
</style> </style>

View File

@@ -1,28 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui'
import { import {
UserPlusIcon, MailIcon,
MoreVerticalIcon, MoreVerticalIcon,
MailIcon, SettingsIcon,
SettingsIcon, TrashIcon,
TrashIcon, UserPlusIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { ref, onUnmounted, watch, computed } from 'vue' import {
import { friend_listener } from '@/helpers/events' Avatar,
import { friends, friend_statuses, add_friend, remove_friend } from '@/helpers/friends' ButtonStyled,
import { get_user_many } from '@/helpers/cache' injectNotificationManager,
import { handleError } from '@/store/notifications.js' OverflowMenu,
import ContextMenu from '@/components/ui/ContextMenu.vue' useRelativeTime,
} from '@modrinth/ui'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import { computed, onUnmounted, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_user_many } from '@/helpers/cache'
import { friend_listener } from '@/helpers/events'
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends'
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const props = defineProps<{ const props = defineProps<{
credentials: unknown | null credentials: unknown | null
signIn: () => void signIn: () => void
}>() }>()
const userCredentials = computed(() => props.credentials) const userCredentials = computed(() => props.credentials)
@@ -34,328 +41,328 @@ const friendInvitesModal = ref()
const username = ref('') const username = ref('')
const addFriendModal = ref() const addFriendModal = ref()
async function addFriendFromModal() { async function addFriendFromModal() {
addFriendModal.value.hide() addFriendModal.value.hide()
await add_friend(username.value).catch(handleError) await add_friend(username.value).catch(handleError)
username.value = '' username.value = ''
await loadFriends() await loadFriends()
} }
const friendOptions = ref() const friendOptions = ref()
async function handleFriendOptions(args) { async function handleFriendOptions(args) {
switch (args.option) { switch (args.option) {
case 'remove-friend': case 'remove-friend':
await removeFriend(args.item) await removeFriend(args.item)
break break
} }
} }
async function addFriend(friend: Friend) { async function addFriend(friend: Friend) {
await add_friend( await add_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id, friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError) ).catch(handleError)
await loadFriends() await loadFriends()
} }
async function removeFriend(friend: Friend) { async function removeFriend(friend: Friend) {
await remove_friend( await remove_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id, friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError) ).catch(handleError)
await loadFriends() await loadFriends()
} }
type Friend = { type Friend = {
id: string id: string
friend_id: string | null friend_id: string | null
status: string | null status: string | null
last_updated: Dayjs | null last_updated: Dayjs | null
created: Dayjs created: Dayjs
username: string username: string
accepted: boolean accepted: boolean
online: boolean online: boolean
avatar: string avatar: string
} }
const userFriends = ref<Friend[]>([]) const userFriends = ref<Friend[]>([])
const acceptedFriends = computed(() => const acceptedFriends = computed(() =>
userFriends.value userFriends.value
.filter((x) => x.accepted) .filter((x) => x.accepted)
.toSorted((a, b) => { .toSorted((a, b) => {
if (a.last_updated === null && b.last_updated === null) { if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting return 0 // Both are null, equal in sorting
} }
if (a.last_updated === null) { if (a.last_updated === null) {
return 1 // `a` is null, move it after `b` return 1 // `a` is null, move it after `b`
} }
if (b.last_updated === null) { if (b.last_updated === null) {
return -1 // `b` is null, move it after `a` return -1 // `b` is null, move it after `a`
} }
// Both are non-null, sort by date // Both are non-null, sort by date
return b.last_updated.diff(a.last_updated) return b.last_updated.diff(a.last_updated)
}), }),
) )
const pendingFriends = computed(() => const pendingFriends = computed(() =>
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)), userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)),
) )
const loading = ref(true) const loading = ref(true)
async function loadFriends(timeout = false) { async function loadFriends(timeout = false) {
loading.value = timeout loading.value = timeout
try { try {
const friendsList = await friends() const friendsList = await friends()
if (friendsList.length === 0) { if (friendsList.length === 0) {
userFriends.value = [] userFriends.value = []
} else { } else {
const friendStatuses = await friend_statuses() const friendStatuses = await friend_statuses()
const users = await get_user_many( const users = await get_user_many(
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)), friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
) )
userFriends.value = friendsList.map((friend) => { userFriends.value = friendsList.map((friend) => {
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id) const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
const status = friendStatuses.find( const status = friendStatuses.find(
(x) => x.user_id === friend.id || x.user_id === friend.friend_id, (x) => x.user_id === friend.id || x.user_id === friend.friend_id,
) )
return { return {
id: friend.id, id: friend.id,
friend_id: friend.friend_id, friend_id: friend.friend_id,
status: status?.profile_name, status: status?.profile_name,
last_updated: status && status.last_update ? dayjs(status.last_update) : null, last_updated: status && status.last_update ? dayjs(status.last_update) : null,
created: dayjs(friend.created), created: dayjs(friend.created),
avatar: user?.avatar_url, avatar: user?.avatar_url,
username: user?.username, username: user?.username,
online: !!status, online: !!status,
accepted: friend.accepted, accepted: friend.accepted,
} }
}) })
} }
loading.value = false loading.value = false
} catch (e) { } catch (e) {
console.error('Error loading friends', e) console.error('Error loading friends', e)
if (timeout) { if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000) setTimeout(() => loadFriends(), 15 * 1000)
} }
} }
} }
watch( watch(
userCredentials, userCredentials,
() => { () => {
if (userCredentials.value === undefined) { if (userCredentials.value === undefined) {
userFriends.value = [] userFriends.value = []
} else if (userCredentials.value === null) { } else if (userCredentials.value === null) {
userFriends.value = [] userFriends.value = []
loading.value = false loading.value = false
} else { } else {
loadFriends(true) loadFriends(true)
} }
}, },
{ immediate: true }, { immediate: true },
) )
const unlisten = await friend_listener(() => loadFriends()) const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => { onUnmounted(() => {
unlisten() unlisten()
}) })
</script> </script>
<template> <template>
<ModalWrapper ref="manageFriendsModal" header="Manage friends"> <ModalWrapper ref="manageFriendsModal" header="Manage friends">
<p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p> <p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p>
<div v-else class="flex flex-col gap-4 min-w-[20rem]"> <div v-else class="flex flex-col gap-4 min-w-[20rem]">
<input v-model="search" type="text" placeholder="Search friends..." class="w-full" /> <input v-model="search" type="text" placeholder="Search friends..." class="w-full" />
<div <div
v-for="friend in acceptedFriends.filter( v-for="friend in acceptedFriends.filter(
(x) => !search || x.username.toLowerCase().includes(search), (x) => !search || x.username.toLowerCase().includes(search),
)" )"
:key="friend.username" :key="friend.username"
class="flex gap-2 items-center" class="flex gap-2 items-center"
> >
<div class="relative"> <div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle /> <Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span <span
v-if="friend.online" v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full" class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/> />
</div> </div>
<div>{{ friend.username }}</div> <div>{{ friend.username }}</div>
<div class="ml-auto"> <div class="ml-auto">
<ButtonStyled> <ButtonStyled>
<button @click="removeFriend(friend)"> <button @click="removeFriend(friend)">
<XIcon /> <XIcon />
Remove Remove
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="friendInvitesModal" header="View friend requests"> <ModalWrapper ref="friendInvitesModal" header="View friend requests">
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p> <p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4"> <div v-else class="flex flex-col gap-4">
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2"> <div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle /> <Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div> <div>
<p class="m-0"> <p class="m-0">
<template v-if="friend.id === userCredentials.user_id"> <template v-if="friend.id === userCredentials.user_id">
<span class="font-bold">{{ friend.username }}</span> sent you a friend request <span class="font-bold">{{ friend.username }}</span> sent you a friend request
</template> </template>
<template v-else> <template v-else>
You sent <span class="font-bold">{{ friend.username }}</span> a friend request You sent <span class="font-bold">{{ friend.username }}</span> a friend request
</template> </template>
</p> </p>
<p class="m-0 text-sm text-secondary"> <p class="m-0 text-sm text-secondary">
{{ formatRelativeTime(friend.created.toISOString()) }} {{ formatRelativeTime(friend.created.toISOString()) }}
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id"> <template v-if="friend.id === userCredentials.user_id">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button @click="addFriend(friend)"> <button @click="addFriend(friend)">
<UserPlusIcon /> <UserPlusIcon />
Accept Accept
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button @click="removeFriend(friend)"> <button @click="removeFriend(friend)">
<XIcon /> <XIcon />
Ignore Ignore
</button> </button>
</ButtonStyled> </ButtonStyled>
</template> </template>
<template v-else> <template v-else>
<ButtonStyled> <ButtonStyled>
<button @click="removeFriend(friend)"> <button @click="removeFriend(friend)">
<XIcon /> <XIcon />
Cancel Cancel
</button> </button>
</ButtonStyled> </ButtonStyled>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="addFriendModal" header="Add a friend"> <ModalWrapper ref="addFriendModal" header="Add a friend">
<div class="mb-4"> <div class="mb-4">
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2>
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p> <p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
<input v-model="username" class="mt-2 w-full" type="text" placeholder="Enter username..." /> <input v-model="username" class="mt-2 w-full" type="text" placeholder="Enter username..." />
</div> </div>
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal"> <button :disabled="username.length === 0" @click="addFriendFromModal">
<UserPlusIcon /> <UserPlusIcon />
Add friend Add friend
</button> </button>
</ButtonStyled> </ButtonStyled>
</ModalWrapper> </ModalWrapper>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-lg m-0">Friends</h3> <h3 class="text-lg m-0">Friends</h3>
<ButtonStyled v-if="userCredentials" type="transparent" circular> <ButtonStyled v-if="userCredentials" type="transparent" circular>
<OverflowMenu <OverflowMenu
:options="[ :options="[
{ {
id: 'add-friend', id: 'add-friend',
action: () => addFriendModal.show(), action: () => addFriendModal.show(),
}, },
{ {
id: 'manage-friends', id: 'manage-friends',
action: () => manageFriendsModal.show(), action: () => manageFriendsModal.show(),
shown: acceptedFriends.length > 0, shown: acceptedFriends.length > 0,
}, },
{ {
id: 'view-requests', id: 'view-requests',
action: () => friendInvitesModal.show(), action: () => friendInvitesModal.show(),
shown: pendingFriends.length > 0, shown: pendingFriends.length > 0,
}, },
]" ]"
aria-label="More options" aria-label="More options"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #add-friend> <template #add-friend>
<UserPlusIcon aria-hidden="true" /> <UserPlusIcon aria-hidden="true" />
Add friend Add friend
</template> </template>
<template #manage-friends> <template #manage-friends>
<SettingsIcon aria-hidden="true" /> <SettingsIcon aria-hidden="true" />
Manage friends Manage friends
<div <div
v-if="acceptedFriends.length > 0" v-if="acceptedFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center" class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
> >
{{ acceptedFriends.length }} {{ acceptedFriends.length }}
</div> </div>
</template> </template>
<template #view-requests> <template #view-requests>
<MailIcon aria-hidden="true" /> <MailIcon aria-hidden="true" />
View friend requests View friend requests
<div <div
v-if="pendingFriends.length > 0" v-if="pendingFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center" class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
> >
{{ pendingFriends.length }} {{ pendingFriends.length }}
</div> </div>
</template> </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div class="flex flex-col gap-2 mt-2"> <div class="flex flex-col gap-2 mt-2">
<template v-if="loading"> <template v-if="loading">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse"> <div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div> <div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div> <div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
<div class="h-2.5 bg-button-bg rounded-full w-3/4"></div> <div class="h-2.5 bg-button-bg rounded-full w-3/4"></div>
</div> </div>
</div> </div>
</template> </template>
<template v-else-if="acceptedFriends.length === 0"> <template v-else-if="acceptedFriends.length === 0">
<div class="text-sm"> <div class="text-sm">
<div v-if="!userCredentials"> <div v-if="!userCredentials">
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends! <span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends!
</div> </div>
<div v-else> <div v-else>
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span> <span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span>
to share what you're playing! to share what you're playing!
</div> </div>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions"> <ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #remove-friend> <TrashIcon /> Remove friend </template> <template #remove-friend> <TrashIcon /> Remove friend </template>
</ContextMenu> </ContextMenu>
<div <div
v-for="friend in acceptedFriends.slice(0, 5)" v-for="friend in acceptedFriends.slice(0, 5)"
:key="friend.username" :key="friend.username"
class="flex gap-2 items-center" class="flex gap-2 items-center"
:class="{ grayscale: !friend.online }" :class="{ grayscale: !friend.online }"
@contextmenu.prevent.stop=" @contextmenu.prevent.stop="
(event) => (event) =>
friendOptions.showMenu(event, friend, [ friendOptions.showMenu(event, friend, [
{ {
name: 'remove-friend', name: 'remove-friend',
color: 'danger', color: 'danger',
}, },
]) ])
" "
> >
<div class="relative"> <div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle /> <Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span <span
v-if="friend.online" v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full" class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/> />
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }"> <span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
{{ friend.username }} {{ friend.username }}
</span> </span>
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span> <span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div> </div>
</div> </div>
</template> </template>
</div> </div>
</template> </template>

View File

@@ -1,71 +1,73 @@
<template> <template>
<ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall"> <ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall">
<div class="modal-body"> <div class="modal-body">
<p> <p>
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
you're trying to install it on. Are you sure you want to continue? Dependencies will not be you're trying to install it on. Are you sure you want to continue? Dependencies will not be
installed. installed.
</p> </p>
<table> <table>
<thead> <thead>
<tr class="header"> <tr class="header">
<th>{{ instance?.name }}</th> <th>{{ instance?.name }}</th>
<th>{{ project.title }}</th> <th>{{ project.title }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr class="content"> <tr class="content">
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td> <td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
<td> <td>
<multiselect <multiselect
v-if="versions?.length > 1" v-if="versions?.length > 1"
v-model="selectedVersion" v-model="selectedVersion"
:options="versions" :options="versions"
:searchable="true" :searchable="true"
placeholder="Select version" placeholder="Select version"
open-direction="top" open-direction="top"
:show-labels="false" :show-labels="false"
:custom-label=" :custom-label="
(version) => (version) =>
`${version?.name} (${version?.loaders `${version?.name} (${version?.loaders
.map((name) => formatCategory(name)) .map((name) => formatCategory(name))
.join(', ')} - ${version?.game_versions.join(', ')})` .join(', ')} - ${version?.game_versions.join(', ')})`
" "
:max-height="150" :max-height="150"
/> />
<span v-else> <span v-else>
<span> <span>
{{ selectedVersion?.name }} ({{ {{ selectedVersion?.name }} ({{
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ') selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
}} }}
- {{ selectedVersion?.game_versions.join(', ') }}) - {{ selectedVersion?.game_versions.join(', ') }})
</span> </span>
</span> </span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="button-group"> <div class="button-group">
<Button @click="() => incompatibleModal.hide()"><XIcon />Cancel</Button> <Button @click="() => incompatibleModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" :disabled="installing" @click="install()"> <Button color="primary" :disabled="installing" @click="install()">
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }} <DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
</Button> </Button>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import { DownloadIcon, XIcon } from '@modrinth/assets'
import { XIcon, DownloadIcon } from '@modrinth/assets' import { Button, injectNotificationManager } from '@modrinth/ui'
import { Button } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils' import { formatCategory } from '@modrinth/utils'
import { add_project_from_version as installMod } from '@/helpers/profile'
import { ref } from 'vue' import { ref } from 'vue'
import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
import Multiselect from 'vue-multiselect' import Multiselect from 'vue-multiselect'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { add_project_from_version as installMod } from '@/helpers/profile'
const { handleError } = injectNotificationManager()
const instance = ref(null) const instance = ref(null)
const project = ref(null) const project = ref(null)
const versions = ref(null) const versions = ref(null)
@@ -76,91 +78,91 @@ const installing = ref(false)
const onInstall = ref(() => {}) const onInstall = ref(() => {})
defineExpose({ defineExpose({
show: (instanceVal, projectVal, projectVersions, callback) => { show: (instanceVal, projectVal, projectVersions, selected, callback) => {
instance.value = instanceVal instance.value = instanceVal
versions.value = projectVersions versions.value = projectVersions
selectedVersion.value = projectVersions[0] selectedVersion.value = selected ?? projectVersions[0]
project.value = projectVal project.value = projectVal
onInstall.value = callback onInstall.value = callback
installing.value = false installing.value = false
incompatibleModal.value.show() incompatibleModal.value.show()
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' }) trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
}, },
}) })
const install = async () => { const install = async () => {
installing.value = true installing.value = true
await installMod(instance.value.path, selectedVersion.value.id).catch(handleError) await installMod(instance.value.path, selectedVersion.value.id).catch(handleError)
installing.value = false installing.value = false
onInstall.value(selectedVersion.value.id) onInstall.value(selectedVersion.value.id)
incompatibleModal.value.hide() incompatibleModal.value.hide()
trackEvent('ProjectInstall', { trackEvent('ProjectInstall', {
loader: instance.value.loader, loader: instance.value.loader,
game_version: instance.value.game_version, game_version: instance.value.game_version,
id: project.value, id: project.value,
version_id: selectedVersion.value.id, version_id: selectedVersion.value.id,
project_type: project.value.project_type, project_type: project.value.project_type,
title: project.value.title, title: project.value.title,
source: 'ProjectIncompatibilityWarningModal', source: 'ProjectIncompatibilityWarningModal',
}) })
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.data { .data {
text-transform: capitalize; text-transform: capitalize;
} }
table { table {
width: 100%; width: 100%;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border-collapse: collapse; border-collapse: collapse;
box-shadow: 0 0 0 1px var(--color-button-bg); box-shadow: 0 0 0 1px var(--color-button-bg);
} }
th { th {
text-align: left; text-align: left;
padding: 1rem; padding: 1rem;
background-color: var(--color-bg); background-color: var(--color-bg);
overflow: hidden; overflow: hidden;
border-bottom: 1px solid var(--color-button-bg); border-bottom: 1px solid var(--color-button-bg);
} }
th:first-child { th:first-child {
border-top-left-radius: var(--radius-lg); border-top-left-radius: var(--radius-lg);
border-right: 1px solid var(--color-button-bg); border-right: 1px solid var(--color-button-bg);
} }
th:last-child { th:last-child {
border-top-right-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg);
} }
td { td {
padding: 1rem; padding: 1rem;
} }
td:first-child { td:first-child {
border-right: 1px solid var(--color-button-bg); border-right: 1px solid var(--color-button-bg);
} }
.button-group { .button-group {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 1rem; gap: 1rem;
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
:deep(.animated-dropdown .options) { :deep(.animated-dropdown .options) {
max-height: 13.375rem; max-height: 13.375rem;
} }
} }
</style> </style>

View File

@@ -1,11 +1,13 @@
<script setup> <script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets' import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui' import { Button, injectNotificationManager } from '@modrinth/ui'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { ref } from 'vue' import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { handleError } from '@/store/state.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
const { handleError } = injectNotificationManager()
const versionId = ref() const versionId = ref()
const project = ref() const project = ref()
@@ -16,60 +18,60 @@ const onInstall = ref(() => {})
const onCreateInstance = ref(() => {}) const onCreateInstance = ref(() => {})
defineExpose({ defineExpose({
show: (projectVal, versionIdVal, callback, createInstanceCallback) => { show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
project.value = projectVal project.value = projectVal
versionId.value = versionIdVal versionId.value = versionIdVal
installing.value = false installing.value = false
confirmModal.value.show() confirmModal.value.show()
onInstall.value = callback onInstall.value = callback
onCreateInstance.value = createInstanceCallback onCreateInstance.value = createInstanceCallback
trackEvent('PackInstallStart') trackEvent('PackInstallStart')
}, },
}) })
async function install() { async function install() {
installing.value = true installing.value = true
confirmModal.value.hide() confirmModal.value.hide()
await pack_install( await pack_install(
project.value.id, project.value.id,
versionId.value, versionId.value,
project.value.title, project.value.title,
project.value.icon_url, project.value.icon_url,
onCreateInstance.value, onCreateInstance.value,
).catch(handleError) ).catch(handleError)
trackEvent('PackInstall', { trackEvent('PackInstall', {
id: project.value.id, id: project.value.id,
version_id: versionId.value, version_id: versionId.value,
title: project.value.title, title: project.value.title,
source: 'ConfirmModal', source: 'ConfirmModal',
}) })
onInstall.value(versionId.value) onInstall.value(versionId.value)
installing.value = false installing.value = false
} }
</script> </script>
<template> <template>
<ModalWrapper ref="confirmModal" header="Are you sure?" :on-hide="onInstall"> <ModalWrapper ref="confirmModal" header="Are you sure?" :on-hide="onInstall">
<div class="modal-body"> <div class="modal-body">
<p>You already have this modpack installed. Are you sure you want to install it again?</p> <p>You already have this modpack installed. Are you sure you want to install it again?</p>
<div class="input-group push-right"> <div class="input-group push-right">
<Button @click="() => $refs.confirmModal.hide()"><XIcon />Cancel</Button> <Button @click="() => $refs.confirmModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" :disabled="installing" @click="install()" <Button color="primary" :disabled="installing" @click="install()"
><DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}</Button ><DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}</Button
> >
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
</style> </style>

View File

@@ -1,29 +1,30 @@
<script setup> <script setup>
import { import {
DownloadIcon, CheckIcon,
PlusIcon, DownloadIcon,
UploadIcon, PlusIcon,
XIcon, RightArrowIcon,
RightArrowIcon, UploadIcon,
CheckIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui' import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
add_project_from_version as installMod,
check_installed,
get,
list,
create,
} from '@/helpers/profile'
import { open } from '@tauri-apps/plugin-dialog'
import { installVersionDependencies } from '@/store/install.js'
import { handleError } from '@/store/notifications.js'
import { useRouter } from 'vue-router'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { trackEvent } from '@/helpers/analytics' import { open } from '@tauri-apps/plugin-dialog'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import {
add_project_from_version as installMod,
check_installed,
create,
get,
list,
} from '@/helpers/profile'
import { installVersionDependencies } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const router = useRouter() const router = useRouter()
const versions = ref() const versions = ref()
@@ -43,361 +44,361 @@ const creatingInstance = ref(false)
const profiles = ref([]) const profiles = ref([])
const shownProfiles = computed(() => const shownProfiles = computed(() =>
profiles.value profiles.value
.filter((profile) => { .filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase()) return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}) })
.filter((profile) => { .filter((profile) => {
const loaders = versions.value.flatMap((v) => v.loaders) const loaders = versions.value.flatMap((v) => v.loaders)
return ( return (
versions.value.flatMap((v) => v.game_versions).includes(profile.game_version) && versions.value.flatMap((v) => v.game_versions).includes(profile.game_version) &&
(project.value.project_type === 'mod' (project.value.project_type === 'mod'
? loaders.includes(profile.loader) || loaders.includes('minecraft') ? loaders.includes(profile.loader) || loaders.includes('minecraft')
: true) : true)
) )
}), }),
) )
const onInstall = ref(() => {}) const onInstall = ref(() => {})
defineExpose({ defineExpose({
show: async (projectVal, versionsVal, callback) => { show: async (projectVal, versionsVal, callback) => {
project.value = projectVal project.value = projectVal
versions.value = versionsVal versions.value = versionsVal
searchFilter.value = '' searchFilter.value = ''
showCreation.value = false showCreation.value = false
name.value = null name.value = null
icon.value = null icon.value = null
display_icon.value = null display_icon.value = null
gameVersion.value = null gameVersion.value = null
loader.value = null loader.value = null
onInstall.value = callback onInstall.value = callback
const profilesVal = await list().catch(handleError) const profilesVal = await list().catch(handleError)
for (const profile of profilesVal) { for (const profile of profilesVal) {
profile.installing = false profile.installing = false
profile.installedMod = await check_installed(profile.path, project.value.id).catch( profile.installedMod = await check_installed(profile.path, project.value.id).catch(
handleError, handleError,
) )
} }
profiles.value = profilesVal profiles.value = profilesVal
installModal.value.show() installModal.value.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' }) trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
}, },
}) })
async function install(instance) { async function install(instance) {
instance.installing = true instance.installing = true
const version = versions.value.find((v) => { const version = versions.value.find((v) => {
return ( return (
v.game_versions.includes(instance.game_version) && v.game_versions.includes(instance.game_version) &&
(project.value.project_type === 'mod' (project.value.project_type === 'mod'
? v.loaders.includes(instance.loader) || v.loaders.includes('minecraft') ? v.loaders.includes(instance.loader) || v.loaders.includes('minecraft')
: true) : true)
) )
}) })
if (!version) { if (!version) {
instance.installing = false instance.installing = false
handleError('No compatible version found') handleError('No compatible version found')
return return
} }
await installMod(instance.path, version.id).catch(handleError) await installMod(instance.path, version.id).catch(handleError)
await installVersionDependencies(instance, version) await installVersionDependencies(instance, version).catch(handleError)
instance.installedMod = true instance.installedMod = true
instance.installing = false instance.installing = false
trackEvent('ProjectInstall', { trackEvent('ProjectInstall', {
loader: instance.loader, loader: instance.loader,
game_version: instance.game_version, game_version: instance.game_version,
id: project.value.id, id: project.value.id,
version_id: version.id, version_id: version.id,
project_type: project.value.project_type, project_type: project.value.project_type,
title: project.value.title, title: project.value.title,
source: 'ProjectInstallModal', source: 'ProjectInstallModal',
}) })
onInstall.value(version.id) onInstall.value(version.id)
} }
const toggleCreation = () => { const toggleCreation = () => {
showCreation.value = !showCreation.value showCreation.value = !showCreation.value
name.value = null name.value = null
icon.value = null icon.value = null
display_icon.value = null display_icon.value = null
gameVersion.value = null gameVersion.value = null
loader.value = null loader.value = null
if (showCreation.value) { if (showCreation.value) {
trackEvent('InstanceCreateStart', { source: 'ProjectInstallModal' }) trackEvent('InstanceCreateStart', { source: 'ProjectInstallModal' })
} }
} }
const upload_icon = async () => { const upload_icon = async () => {
const res = await open({ const res = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: 'Image', name: 'Image',
extensions: ['png', 'jpeg'], extensions: ['png', 'jpeg'],
}, },
], ],
}) })
icon.value = res.path ?? res icon.value = res.path ?? res
if (!icon.value) return if (!icon.value) return
display_icon.value = convertFileSrc(icon.value) display_icon.value = convertFileSrc(icon.value)
} }
const reset_icon = () => { const reset_icon = () => {
icon.value = null icon.value = null
display_icon.value = null display_icon.value = null
} }
const createInstance = async () => { const createInstance = async () => {
creatingInstance.value = true creatingInstance.value = true
const loader = const loader =
versions.value[0].loaders[0] !== 'forge' && versions.value[0].loaders[0] !== 'forge' &&
versions.value[0].loaders[0] !== 'fabric' && versions.value[0].loaders[0] !== 'fabric' &&
versions.value[0].loaders[0] !== 'quilt' versions.value[0].loaders[0] !== 'quilt'
? 'vanilla' ? 'vanilla'
: versions.value[0].loaders[0] : versions.value[0].loaders[0]
const id = await create( const id = await create(
name.value, name.value,
versions.value[0].game_versions[0], versions.value[0].game_versions[0],
loader, loader,
'latest', 'latest',
icon.value, icon.value,
).catch(handleError) ).catch(handleError)
await installMod(id, versions.value[0].id).catch(handleError) await installMod(id, versions.value[0].id).catch(handleError)
await router.push(`/instance/${encodeURIComponent(id)}/`) await router.push(`/instance/${encodeURIComponent(id)}/`)
const instance = await get(id, true) const instance = await get(id, true)
await installVersionDependencies(instance, versions.value[0]) await installVersionDependencies(instance, versions.value[0]).catch(handleError)
trackEvent('InstanceCreate', { trackEvent('InstanceCreate', {
profile_name: name.value, profile_name: name.value,
game_version: versions.value[0].game_versions[0], game_version: versions.value[0].game_versions[0],
loader: loader, loader: loader,
loader_version: 'latest', loader_version: 'latest',
has_icon: !!icon.value, has_icon: !!icon.value,
source: 'ProjectInstallModal', source: 'ProjectInstallModal',
}) })
trackEvent('ProjectInstall', { trackEvent('ProjectInstall', {
loader: loader, loader: loader,
game_version: versions.value[0].game_versions[0], game_version: versions.value[0].game_versions[0],
id: project.value, id: project.value,
version_id: versions.value[0].id, version_id: versions.value[0].id,
project_type: project.value.project_type, project_type: project.value.project_type,
title: project.value.title, title: project.value.title,
source: 'ProjectInstallModal', source: 'ProjectInstallModal',
}) })
onInstall.value(versions.value[0].id) onInstall.value(versions.value[0].id)
if (installModal.value) installModal.value.hide() if (installModal.value) installModal.value.hide()
creatingInstance.value = false creatingInstance.value = false
} }
</script> </script>
<template> <template>
<ModalWrapper ref="installModal" header="Install project to instance" :on-hide="onInstall"> <ModalWrapper ref="installModal" header="Install project to instance" :on-hide="onInstall">
<div class="modal-body"> <div class="modal-body">
<input <input
v-model="searchFilter" v-model="searchFilter"
autocomplete="off" autocomplete="off"
type="text" type="text"
class="search" class="search"
placeholder="Search for an instance" placeholder="Search for an instance"
/> />
<div class="profiles" :class="{ 'hide-creation': !showCreation }"> <div class="profiles" :class="{ 'hide-creation': !showCreation }">
<div v-for="profile in shownProfiles" :key="profile.name" class="option"> <div v-for="profile in shownProfiles" :key="profile.name" class="option">
<router-link <router-link
class="btn btn-transparent profile-button" class="btn btn-transparent profile-button"
:to="`/instance/${encodeURIComponent(profile.path)}`" :to="`/instance/${encodeURIComponent(profile.path)}`"
@click="installModal.hide()" @click="installModal.hide()"
> >
<Avatar <Avatar
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null" :src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
class="profile-image" class="profile-image"
/> />
{{ profile.name }} {{ profile.name }}
</router-link> </router-link>
<div <div
v-tooltip=" v-tooltip="
profile.linked_data?.locked && !profile.installedMod profile.linked_data?.locked && !profile.installedMod
? 'Unpair or unlock an instance to add mods.' ? 'Unpair or unlock an instance to add mods.'
: '' : ''
" "
> >
<Button <Button
:disabled="profile.installedMod || profile.installing" :disabled="profile.installedMod || profile.installing"
@click="install(profile)" @click="install(profile)"
> >
<DownloadIcon v-if="!profile.installedMod && !profile.installing" /> <DownloadIcon v-if="!profile.installedMod && !profile.installing" />
<CheckIcon v-else-if="profile.installedMod" /> <CheckIcon v-else-if="profile.installedMod" />
{{ {{
profile.installing profile.installing
? 'Installing...' ? 'Installing...'
: profile.installedMod : profile.installedMod
? 'Installed' ? 'Installed'
: 'Install' : 'Install'
}} }}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<Card v-if="showCreation" class="creation-card"> <Card v-if="showCreation" class="creation-card">
<div class="creation-container"> <div class="creation-container">
<div class="creation-icon"> <div class="creation-icon">
<Avatar size="md" class="icon" :src="display_icon" /> <Avatar size="md" class="icon" :src="display_icon" />
<div class="creation-icon__description"> <div class="creation-icon__description">
<Button @click="upload_icon()"> <Button @click="upload_icon()">
<UploadIcon /> <UploadIcon />
<span class="no-wrap"> Select icon </span> <span class="no-wrap"> Select icon </span>
</Button> </Button>
<Button :disabled="!display_icon" @click="reset_icon()"> <Button :disabled="!display_icon" @click="reset_icon()">
<XIcon /> <XIcon />
<span class="no-wrap"> Remove icon </span> <span class="no-wrap"> Remove icon </span>
</Button> </Button>
</div> </div>
</div> </div>
<div class="creation-settings"> <div class="creation-settings">
<input <input
v-model="name" v-model="name"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Name" placeholder="Name"
class="creation-input" class="creation-input"
/> />
<Button :disabled="creatingInstance === true || !name" @click="createInstance()"> <Button :disabled="creatingInstance === true || !name" @click="createInstance()">
<RightArrowIcon /> <RightArrowIcon />
{{ creatingInstance ? 'Creating...' : 'Create' }} {{ creatingInstance ? 'Creating...' : 'Create' }}
</Button> </Button>
</div> </div>
</div> </div>
</Card> </Card>
<div class="input-group push-right"> <div class="input-group push-right">
<Button :color="showCreation ? '' : 'primary'" @click="toggleCreation()"> <Button :color="showCreation ? '' : 'primary'" @click="toggleCreation()">
<PlusIcon /> <PlusIcon />
{{ showCreation ? 'Hide New Instance' : 'Create new instance' }} {{ showCreation ? 'Hide New Instance' : 'Create new instance' }}
</Button> </Button>
<Button @click="installModal.hide()">Cancel</Button> <Button @click="installModal.hide()">Cancel</Button>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.creation-card { .creation-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
margin: 0; margin: 0;
background-color: var(--color-bg); background-color: var(--color-bg);
} }
.creation-container { .creation-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
} }
.creation-icon { .creation-icon {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
flex-grow: 1; flex-grow: 1;
.creation-icon__description { .creation-icon__description {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
} }
.creation-input { .creation-input {
width: 100%; width: 100%;
} }
.no-wrap { .no-wrap {
white-space: nowrap; white-space: nowrap;
} }
.creation-dropdown { .creation-dropdown {
width: min-content !important; width: min-content !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.creation-settings { .creation-settings {
width: 100%; width: 100%;
margin-left: 0.5rem; margin-left: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
justify-content: center; justify-content: center;
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
min-width: 350px; min-width: 350px;
} }
.profiles { .profiles {
max-height: 12rem; max-height: 12rem;
overflow-y: auto; overflow-y: auto;
&.hide-creation { &.hide-creation {
max-height: 21rem; max-height: 21rem;
} }
} }
.option { .option {
width: calc(100%); width: calc(100%);
background: var(--color-raised-bg); background: var(--color-raised-bg);
color: var(--color-base); color: var(--color-base);
box-shadow: none; box-shadow: none;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
img { img {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.name { .name {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
} }
.profile-button { .profile-button {
align-content: start; align-content: start;
padding: 0.5rem; padding: 0.5rem;
text-align: left; text-align: left;
} }
} }
.profile-image { .profile-image {
--size: 2rem !important; --size: 2rem !important;
} }
</style> </style>

View File

@@ -1,17 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Checkbox,
injectNotificationManager,
OverflowMenu,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { SpinnerIcon, TrashIcon, UploadIcon, PlusIcon, EditIcon, CopyIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, OverflowMenu, Checkbox } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import type { InstanceSettingsTabProps, GameInstance } from '../../../helpers/types'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import type { GameInstance, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const router = useRouter() const router = useRouter()
@@ -28,299 +36,299 @@ const newCategoryInput = ref('')
const installing = computed(() => props.instance.install_stage !== 'installed') const installing = computed(() => props.instance.install_stage !== 'installed')
async function duplicateProfile() { async function duplicateProfile() {
await duplicate(props.instance.path).catch(handleError) await duplicate(props.instance.path).catch(handleError)
trackEvent('InstanceDuplicate', { trackEvent('InstanceDuplicate', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
}) })
} }
const allInstances = ref((await list()) as GameInstance[]) const allInstances = ref((await list()) as GameInstance[])
const availableGroups = computed(() => [ const availableGroups = computed(() => [
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]), ...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
]) ])
async function resetIcon() { async function resetIcon() {
icon.value = undefined icon.value = undefined
await edit_icon(props.instance.path, null).catch(handleError) await edit_icon(props.instance.path, null).catch(handleError)
trackEvent('InstanceRemoveIcon') trackEvent('InstanceRemoveIcon')
} }
async function setIcon() { async function setIcon() {
const value = await open({ const value = await open({
multiple: false, multiple: false,
filters: [ filters: [
{ {
name: 'Image', name: 'Image',
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'], extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
}, },
], ],
}) })
if (!value) return if (!value) return
icon.value = value icon.value = value
await edit_icon(props.instance.path, icon.value).catch(handleError) await edit_icon(props.instance.path, icon.value).catch(handleError)
trackEvent('InstanceSetIcon') trackEvent('InstanceSetIcon')
} }
const editProfileObject = computed(() => ({ const editProfileObject = computed(() => ({
name: title.value.trim().substring(0, 32) ?? 'Instance', name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0), groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
})) }))
const toggleGroup = (group: string) => { const toggleGroup = (group: string) => {
if (groups.value.includes(group)) { if (groups.value.includes(group)) {
groups.value = groups.value.filter((x) => x !== group) groups.value = groups.value.filter((x) => x !== group)
} else { } else {
groups.value.push(group) groups.value.push(group)
} }
} }
const addCategory = () => { const addCategory = () => {
const text = newCategoryInput.value.trim() const text = newCategoryInput.value.trim()
if (text.length > 0) { if (text.length > 0) {
groups.value.push(text.substring(0, 32)) groups.value.push(text.substring(0, 32))
newCategoryInput.value = '' newCategoryInput.value = ''
} }
} }
watch( watch(
[title, groups, groups], [title, groups, groups],
async () => { async () => {
await edit(props.instance.path, editProfileObject.value) await edit(props.instance.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )
const removing = ref(false) const removing = ref(false)
async function removeProfile() { async function removeProfile() {
removing.value = true removing.value = true
await remove(props.instance.path).catch(handleError) await remove(props.instance.path).catch(handleError)
removing.value = false removing.value = false
trackEvent('InstanceRemove', { trackEvent('InstanceRemove', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
}) })
await router.push({ path: '/' }) await router.push({ path: '/' })
} }
const messages = defineMessages({ const messages = defineMessages({
name: { name: {
id: 'instance.settings.tabs.general.name', id: 'instance.settings.tabs.general.name',
defaultMessage: 'Name', defaultMessage: 'Name',
}, },
libraryGroups: { libraryGroups: {
id: 'instance.settings.tabs.general.library-groups', id: 'instance.settings.tabs.general.library-groups',
defaultMessage: 'Library groups', defaultMessage: 'Library groups',
}, },
libraryGroupsDescription: { libraryGroupsDescription: {
id: 'instance.settings.tabs.general.library-groups.description', id: 'instance.settings.tabs.general.library-groups.description',
defaultMessage: defaultMessage:
'Library groups allow you to organize your instances into different sections in your library.', 'Library groups allow you to organize your instances into different sections in your library.',
}, },
libraryGroupsEnterName: { libraryGroupsEnterName: {
id: 'instance.settings.tabs.general.library-groups.enter-name', id: 'instance.settings.tabs.general.library-groups.enter-name',
defaultMessage: 'Enter group name', defaultMessage: 'Enter group name',
}, },
libraryGroupsCreate: { libraryGroupsCreate: {
id: 'instance.settings.tabs.general.library-groups.create', id: 'instance.settings.tabs.general.library-groups.create',
defaultMessage: 'Create new group', defaultMessage: 'Create new group',
}, },
editIcon: { editIcon: {
id: 'instance.settings.tabs.general.edit-icon', id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon', defaultMessage: 'Edit icon',
}, },
selectIcon: { selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select', id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon', defaultMessage: 'Select icon',
}, },
replaceIcon: { replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace', id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon', defaultMessage: 'Replace icon',
}, },
removeIcon: { removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove', id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon', defaultMessage: 'Remove icon',
}, },
duplicateInstance: { duplicateInstance: {
id: 'instance.settings.tabs.general.duplicate-instance', id: 'instance.settings.tabs.general.duplicate-instance',
defaultMessage: 'Duplicate instance', defaultMessage: 'Duplicate instance',
}, },
duplicateInstanceDescription: { duplicateInstanceDescription: {
id: 'instance.settings.tabs.general.duplicate-instance.description', id: 'instance.settings.tabs.general.duplicate-instance.description',
defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.', defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.',
}, },
duplicateButtonTooltipInstalling: { duplicateButtonTooltipInstalling: {
id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing', id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing',
defaultMessage: 'Cannot duplicate while installing.', defaultMessage: 'Cannot duplicate while installing.',
}, },
duplicateButton: { duplicateButton: {
id: 'instance.settings.tabs.general.duplicate-button', id: 'instance.settings.tabs.general.duplicate-button',
defaultMessage: 'Duplicate', defaultMessage: 'Duplicate',
}, },
deleteInstance: { deleteInstance: {
id: 'instance.settings.tabs.general.delete', id: 'instance.settings.tabs.general.delete',
defaultMessage: 'Delete instance', defaultMessage: 'Delete instance',
}, },
deleteInstanceDescription: { deleteInstanceDescription: {
id: 'instance.settings.tabs.general.delete.description', id: 'instance.settings.tabs.general.delete.description',
defaultMessage: defaultMessage:
'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.', 'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.',
}, },
deleteInstanceButton: { deleteInstanceButton: {
id: 'instance.settings.tabs.general.delete.button', id: 'instance.settings.tabs.general.delete.button',
defaultMessage: 'Delete instance', defaultMessage: 'Delete instance',
}, },
deletingInstanceButton: { deletingInstanceButton: {
id: 'instance.settings.tabs.general.deleting.button', id: 'instance.settings.tabs.general.deleting.button',
defaultMessage: 'Deleting...', defaultMessage: 'Deleting...',
}, },
}) })
</script> </script>
<template> <template>
<ConfirmModalWrapper <ConfirmModalWrapper
ref="deleteConfirmModal" ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?" title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be permanently erased, including your worlds. You will not be able to recover it." description="If you proceed, all data for your instance will be permanently erased, including your worlds. You will not be able to recover it."
:has-to-type="false" :has-to-type="false"
proceed-label="Delete" proceed-label="Delete"
:show-ad-on-close="false" :show-ad-on-close="false"
@proceed="removeProfile" @proceed="removeProfile"
/> />
<div class="block"> <div class="block">
<div class="float-end ml-4 relative group"> <div class="float-end ml-4 relative group">
<OverflowMenu <OverflowMenu
v-tooltip="formatMessage(messages.editIcon)" v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform" class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[ :options="[
{ {
id: 'select', id: 'select',
action: () => setIcon(), action: () => setIcon(),
}, },
{ {
id: 'remove', id: 'remove',
color: 'danger', color: 'danger',
action: () => resetIcon(), action: () => resetIcon(),
shown: !!icon, shown: !!icon,
}, },
]" ]"
> >
<Avatar <Avatar
:src="icon ? convertFileSrc(icon) : icon" :src="icon ? convertFileSrc(icon) : icon"
size="108px" size="108px"
class="!border-4 group-hover:brightness-75" class="!border-4 group-hover:brightness-75"
:tint-by="props.instance.path" :tint-by="props.instance.path"
no-shadow no-shadow
/> />
<div class="absolute top-0 right-0 m-2"> <div class="absolute top-0 right-0 m-2">
<div <div
class="p-2 m-0 text-primary flex items-center justify-center aspect-square bg-button-bg rounded-full border-button-border border-solid border-[1px] hovering-icon-shadow" class="p-2 m-0 text-primary flex items-center justify-center aspect-square bg-button-bg rounded-full border-button-border border-solid border-[1px] hovering-icon-shadow"
> >
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" /> <EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
</div> </div>
</div> </div>
<template #select> <template #select>
<UploadIcon /> <UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }} {{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template> </template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template> <template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu> </OverflowMenu>
</div> </div>
<label for="instance-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block"> <label for="instance-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.name) }} {{ formatMessage(messages.name) }}
</label> </label>
<div class="flex"> <div class="flex">
<input <input
id="instance-name" id="instance-name"
v-model="title" v-model="title"
autocomplete="off" autocomplete="off"
maxlength="80" maxlength="80"
class="flex-grow" class="flex-grow"
type="text" type="text"
/> />
</div> </div>
<template v-if="instance.install_stage == 'installed'"> <template v-if="instance.install_stage == 'installed'">
<div> <div>
<h2 <h2
id="duplicate-instance-label" id="duplicate-instance-label"
class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"
> >
{{ formatMessage(messages.duplicateInstance) }} {{ formatMessage(messages.duplicateInstance) }}
</h2> </h2>
<p class="m-0 mb-2"> <p class="m-0 mb-2">
{{ formatMessage(messages.duplicateInstanceDescription) }} {{ formatMessage(messages.duplicateInstanceDescription) }}
</p> </p>
</div> </div>
<ButtonStyled> <ButtonStyled>
<button <button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null" v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label" aria-labelledby="duplicate-instance-label"
:disabled="installing" :disabled="installing"
@click="duplicateProfile" @click="duplicateProfile"
> >
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }} <CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</template> </template>
<h2 class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.libraryGroups) }} {{ formatMessage(messages.libraryGroups) }}
</h2> </h2>
<p class="m-0 mb-2"> <p class="m-0 mb-2">
{{ formatMessage(messages.libraryGroupsDescription) }} {{ formatMessage(messages.libraryGroupsDescription) }}
</p> </p>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<Checkbox <Checkbox
v-for="group in availableGroups" v-for="group in availableGroups"
:key="group" :key="group"
:model-value="groups.includes(group)" :model-value="groups.includes(group)"
:label="group" :label="group"
@click="toggleGroup(group)" @click="toggleGroup(group)"
/> />
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<input <input
v-model="newCategoryInput" v-model="newCategoryInput"
type="text" type="text"
:placeholder="formatMessage(messages.libraryGroupsEnterName)" :placeholder="formatMessage(messages.libraryGroupsEnterName)"
@submit="() => addCategory" @submit="() => addCategory"
/> />
<ButtonStyled> <ButtonStyled>
<button class="w-fit" @click="() => addCategory()"> <button class="w-fit" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }} <PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
<h2 id="delete-instance-label" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="delete-instance-label" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.deleteInstance) }} {{ formatMessage(messages.deleteInstance) }}
</h2> </h2>
<p class="m-0 mb-2"> <p class="m-0 mb-2">
{{ formatMessage(messages.deleteInstanceDescription) }} {{ formatMessage(messages.deleteInstanceDescription) }}
</p> </p>
<ButtonStyled color="red"> <ButtonStyled color="red">
<button <button
aria-labelledby="delete-instance-label" aria-labelledby="delete-instance-label"
:disabled="removing" :disabled="removing"
@click="deleteConfirmModal.show()" @click="deleteConfirmModal.show()"
> >
<SpinnerIcon v-if="removing" class="animate-spin" /> <SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else /> <TrashIcon v-else />
{{ {{
removing removing
? formatMessage(messages.deletingInstanceButton) ? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton) : formatMessage(messages.deleteInstanceButton)
}} }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.hovering-icon-shadow { .hovering-icon-shadow {
box-shadow: var(--shadow-inset-sm), var(--shadow-raised); box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
} }
</style> </style>

View File

@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Checkbox } from '@modrinth/ui' import { Checkbox, injectNotificationManager } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts' import { computed, ref, watch } from 'vue'
import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import type { AppSettings, Hooks, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>() const props = defineProps<InstanceSettingsTabProps>()
@@ -14,139 +16,139 @@ const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideHooks = ref( const overrideHooks = ref(
!!props.instance.hooks.pre_launch || !!props.instance.hooks.pre_launch ||
!!props.instance.hooks.wrapper || !!props.instance.hooks.wrapper ||
!!props.instance.hooks.post_exit, !!props.instance.hooks.post_exit,
) )
const hooks = ref(props.instance.hooks ?? globalSettings.hooks) const hooks = ref(props.instance.hooks ?? globalSettings.hooks)
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
const editProfile: { const editProfile: {
hooks?: Hooks hooks?: Hooks
} = {} } = {}
// When hooks are not overridden per-instance, we want to clear them // When hooks are not overridden per-instance, we want to clear them
editProfile.hooks = overrideHooks.value ? hooks.value : {} editProfile.hooks = overrideHooks.value ? hooks.value : {}
return editProfile return editProfile
}) })
watch( watch(
[overrideHooks, hooks], [overrideHooks, hooks],
async () => { async () => {
await edit(props.instance.path, editProfileObject.value) await edit(props.instance.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )
const messages = defineMessages({ const messages = defineMessages({
hooks: { hooks: {
id: 'instance.settings.tabs.hooks.title', id: 'instance.settings.tabs.hooks.title',
defaultMessage: 'Game launch hooks', defaultMessage: 'Game launch hooks',
}, },
hooksDescription: { hooksDescription: {
id: 'instance.settings.tabs.hooks.description', id: 'instance.settings.tabs.hooks.description',
defaultMessage: defaultMessage:
'Hooks allow advanced users to run certain system commands before and after launching the game.', 'Hooks allow advanced users to run certain system commands before and after launching the game.',
}, },
customHooks: { customHooks: {
id: 'instance.settings.tabs.hooks.custom-hooks', id: 'instance.settings.tabs.hooks.custom-hooks',
defaultMessage: 'Custom launch hooks', defaultMessage: 'Custom launch hooks',
}, },
preLaunch: { preLaunch: {
id: 'instance.settings.tabs.hooks.pre-launch', id: 'instance.settings.tabs.hooks.pre-launch',
defaultMessage: 'Pre-launch', defaultMessage: 'Pre-launch',
}, },
preLaunchDescription: { preLaunchDescription: {
id: 'instance.settings.tabs.hooks.pre-launch.description', id: 'instance.settings.tabs.hooks.pre-launch.description',
defaultMessage: 'Ran before the instance is launched.', defaultMessage: 'Ran before the instance is launched.',
}, },
preLaunchEnter: { preLaunchEnter: {
id: 'instance.settings.tabs.hooks.pre-launch.enter', id: 'instance.settings.tabs.hooks.pre-launch.enter',
defaultMessage: 'Enter pre-launch command...', defaultMessage: 'Enter pre-launch command...',
}, },
wrapper: { wrapper: {
id: 'instance.settings.tabs.hooks.wrapper', id: 'instance.settings.tabs.hooks.wrapper',
defaultMessage: 'Wrapper', defaultMessage: 'Wrapper',
}, },
wrapperDescription: { wrapperDescription: {
id: 'instance.settings.tabs.hooks.wrapper.description', id: 'instance.settings.tabs.hooks.wrapper.description',
defaultMessage: 'Wrapper command for launching Minecraft.', defaultMessage: 'Wrapper command for launching Minecraft.',
}, },
wrapperEnter: { wrapperEnter: {
id: 'instance.settings.tabs.hooks.wrapper.enter', id: 'instance.settings.tabs.hooks.wrapper.enter',
defaultMessage: 'Enter wrapper command...', defaultMessage: 'Enter wrapper command...',
}, },
postExit: { postExit: {
id: 'instance.settings.tabs.hooks.post-exit', id: 'instance.settings.tabs.hooks.post-exit',
defaultMessage: 'Post-exit', defaultMessage: 'Post-exit',
}, },
postExitDescription: { postExitDescription: {
id: 'instance.settings.tabs.hooks.post-exit.description', id: 'instance.settings.tabs.hooks.post-exit.description',
defaultMessage: 'Ran after the game closes.', defaultMessage: 'Ran after the game closes.',
}, },
postExitEnter: { postExitEnter: {
id: 'instance.settings.tabs.hooks.post-exit.enter', id: 'instance.settings.tabs.hooks.post-exit.enter',
defaultMessage: 'Enter post-exit command...', defaultMessage: 'Enter post-exit command...',
}, },
}) })
</script> </script>
<template> <template>
<div> <div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast"> <h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.hooks) }} {{ formatMessage(messages.hooks) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.hooksDescription) }} {{ formatMessage(messages.hooksDescription) }}
</p> </p>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="mt-2" /> <Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="mt-2" />
<h2 class="mt-2 mb-1 text-lg font-extrabold text-contrast"> <h2 class="mt-2 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.preLaunch) }} {{ formatMessage(messages.preLaunch) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }} {{ formatMessage(messages.preLaunchDescription) }}
</p> </p>
<input <input
id="pre-launch" id="pre-launch"
v-model="hooks.pre_launch" v-model="hooks.pre_launch"
autocomplete="off" autocomplete="off"
:disabled="!overrideHooks" :disabled="!overrideHooks"
type="text" type="text"
:placeholder="formatMessage(messages.preLaunchEnter)" :placeholder="formatMessage(messages.preLaunchEnter)"
class="w-full mt-2" class="w-full mt-2"
/> />
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast"> <h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.wrapper) }} {{ formatMessage(messages.wrapper) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.wrapperDescription) }} {{ formatMessage(messages.wrapperDescription) }}
</p> </p>
<input <input
id="wrapper" id="wrapper"
v-model="hooks.wrapper" v-model="hooks.wrapper"
autocomplete="off" autocomplete="off"
:disabled="!overrideHooks" :disabled="!overrideHooks"
type="text" type="text"
:placeholder="formatMessage(messages.wrapperEnter)" :placeholder="formatMessage(messages.wrapperEnter)"
class="w-full mt-2" class="w-full mt-2"
/> />
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast"> <h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.postExit) }} {{ formatMessage(messages.postExit) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.postExitDescription) }} {{ formatMessage(messages.postExitDescription) }}
</p> </p>
<input <input
id="post-exit" id="post-exit"
v-model="hooks.post_exit" v-model="hooks.post_exit"
autocomplete="off" autocomplete="off"
:disabled="!overrideHooks" :disabled="!overrideHooks"
type="text" type="text"
:placeholder="formatMessage(messages.postExitEnter)" :placeholder="formatMessage(messages.postExitEnter)"
class="w-full mt-2" class="w-full mt-2"
/> />
</div> </div>
</template> </template>

View File

@@ -1,20 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { Checkbox, Slider } from '@modrinth/ui'
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets' import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import { computed, readonly, ref, watch } from 'vue' import { Checkbox, injectNotificationManager, Slider } from '@modrinth/ui'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue' import { computed, readonly, ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import type { AppSettings, InstanceSettingsTabProps, MemorySettings } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>() const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
const overrideJavaInstall = ref(!!props.instance.java_path) const overrideJavaInstall = ref(!!props.instance.java_path)
const optimalJava = readonly(await get_optimal_jre_key(props.instance.path).catch(handleError)) const optimalJava = readonly(await get_optimal_jre_key(props.instance.path).catch(handleError))
@@ -22,167 +24,172 @@ const javaInstall = ref({ path: optimalJava.path ?? props.instance.java_path })
const overrideJavaArgs = ref(props.instance.extra_launch_args?.length !== undefined) const overrideJavaArgs = ref(props.instance.extra_launch_args?.length !== undefined)
const javaArgs = ref( const javaArgs = ref(
(props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '), (props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
) )
const overrideEnvVars = ref(props.instance.custom_env_vars?.length !== undefined) const overrideEnvVars = ref(props.instance.custom_env_vars?.length !== undefined)
const envVars = ref( const envVars = ref(
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars) (props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('=')) .map((x) => x.join('='))
.join(' '), .join(' '),
) )
const overrideMemorySettings = ref(!!props.instance.memory) const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.memory) const memory = ref(props.instance.memory ?? globalSettings.memory)
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024) const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
const editProfile: { const editProfile: {
java_path?: string java_path?: string
extra_launch_args?: string[] extra_launch_args?: string[]
custom_env_vars?: string[][] custom_env_vars?: string[][]
memory?: MemorySettings memory?: MemorySettings
} = {} } = {}
if (overrideJavaInstall.value) { if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') { if (javaInstall.value.path !== '') {
editProfile.java_path = javaInstall.value.path.replace('java.exe', 'javaw.exe') editProfile.java_path = javaInstall.value.path.replace('java.exe', 'javaw.exe')
} }
} }
if (overrideJavaArgs.value) { if (overrideJavaArgs.value) {
editProfile.extra_launch_args = javaArgs.value.trim().split(/\s+/).filter(Boolean) editProfile.extra_launch_args = javaArgs.value.trim().split(/\s+/).filter(Boolean)
} }
if (overrideEnvVars.value) { if (overrideEnvVars.value) {
editProfile.custom_env_vars = envVars.value editProfile.custom_env_vars = envVars.value
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
.map((x) => x.split('=').filter(Boolean)) .map((x) => x.split('=').filter(Boolean))
} }
if (overrideMemorySettings.value) { if (overrideMemorySettings.value) {
editProfile.memory = memory.value editProfile.memory = memory.value
} }
return editProfile return editProfile
}) })
watch( watch(
[ [
overrideJavaInstall, overrideJavaInstall,
javaInstall, javaInstall,
overrideJavaArgs, overrideJavaArgs,
javaArgs, javaArgs,
overrideEnvVars, overrideEnvVars,
envVars, envVars,
overrideMemorySettings, overrideMemorySettings,
memory, memory,
], ],
async () => { async () => {
await edit(props.instance.path, editProfileObject.value) await edit(props.instance.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )
const messages = defineMessages({ const messages = defineMessages({
javaInstallation: { javaInstallation: {
id: 'instance.settings.tabs.java.java-installation', id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation', defaultMessage: 'Java installation',
}, },
javaArguments: { javaArguments: {
id: 'instance.settings.tabs.java.java-arguments', id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments', defaultMessage: 'Java arguments',
}, },
javaEnvironmentVariables: { javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables', id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables', defaultMessage: 'Environment variables',
}, },
javaMemory: { javaMemory: {
id: 'instance.settings.tabs.java.java-memory', id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated', defaultMessage: 'Memory allocated',
}, },
hooks: { hooks: {
id: 'instance.settings.tabs.java.hooks', id: 'instance.settings.tabs.java.hooks',
defaultMessage: 'Hooks', defaultMessage: 'Hooks',
}, },
}) })
</script> </script>
<template> <template>
<div> <div>
<h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaInstallation) }} {{ formatMessage(messages.javaInstallation) }}
</h2> </h2>
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2" /> <Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2" />
<template v-if="!overrideJavaInstall"> <template v-if="!overrideJavaInstall">
<div class="flex my-2 items-center gap-2 font-semibold"> <div class="flex my-2 items-center gap-2 font-semibold">
<template v-if="javaInstall"> <template v-if="javaInstall">
<CheckCircleIcon class="text-brand-green h-4 w-4" /> <CheckCircleIcon class="text-brand-green h-4 w-4" />
<span>Using default Java {{ optimalJava.major_version }} installation:</span> <span>Using default Java {{ optimalJava.major_version }} installation:</span>
</template> </template>
<template v-else-if="optimalJava"> <template v-else-if="optimalJava">
<XCircleIcon class="text-brand-red h-5 w-5" /> <XCircleIcon class="text-brand-red h-5 w-5" />
<span <span
>Could not find a default Java {{ optimalJava.major_version }} installation. Please set >Could not find a default Java {{ optimalJava.major_version }} installation. Please set
one below:</span one below:</span
> >
</template> </template>
<template v-else> <template v-else>
<XCircleIcon class="text-brand-red h-5 w-5" /> <XCircleIcon class="text-brand-red h-5 w-5" />
<span <span
>Could not automatically determine a Java installation to use. Please set one >Could not automatically determine a Java installation to use. Please set one
below:</span below:</span
> >
</template> </template>
</div> </div>
<div <div
v-if="javaInstall && !overrideJavaInstall" v-if="javaInstall && !overrideJavaInstall"
class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono" class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono"
> >
{{ javaInstall.path }} {{ javaInstall.path }}
</div> </div>
</template> </template>
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" /> <JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaMemory) }} {{ formatMessage(messages.javaMemory) }}
</h2> </h2>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2" /> <Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2" />
<Slider <Slider
id="max-memory" id="max-memory"
v-model="memory.maximum" v-model="memory.maximum"
:disabled="!overrideMemorySettings" :disabled="!overrideMemorySettings"
:min="512" :min="512"
:max="maxMemory" :max="maxMemory"
:step="64" :step="64"
unit="MB" :snap-points="snapPoints"
/> :snap-range="512"
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block"> unit="MB"
{{ formatMessage(messages.javaArguments) }} />
</h2> <h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" /> {{ formatMessage(messages.javaArguments) }}
<input </h2>
id="java-args" <Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
v-model="javaArgs" <input
autocomplete="off" id="java-args"
:disabled="!overrideJavaArgs" v-model="javaArgs"
type="text" autocomplete="off"
class="w-full" :disabled="!overrideJavaArgs"
placeholder="Enter java arguments..." type="text"
/> class="w-full"
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block"> placeholder="Enter java arguments..."
{{ formatMessage(messages.javaEnvironmentVariables) }} />
</h2> <h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2" /> {{ formatMessage(messages.javaEnvironmentVariables) }}
<input </h2>
id="env-vars" <Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2" />
v-model="envVars" <input
autocomplete="off" id="env-vars"
:disabled="!overrideEnvVars" v-model="envVars"
type="text" autocomplete="off"
class="w-full" :disabled="!overrideEnvVars"
placeholder="Enter environmental variables..." type="text"
/> class="w-full"
</div> placeholder="Enter environmental variables..."
/>
</div>
</template> </template>

View File

@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Checkbox, Toggle } from '@modrinth/ui' import { Checkbox, injectNotificationManager, Toggle } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts' import { computed, type Ref, ref, watch } from 'vue'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types' import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>() const props = defineProps<InstanceSettingsTabProps>()
@@ -14,151 +16,151 @@ const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideWindowSettings = ref( const overrideWindowSettings = ref(
!!props.instance.game_resolution || !!props.instance.force_fullscreen, !!props.instance.game_resolution || !!props.instance.force_fullscreen,
) )
const resolution: Ref<[number, number]> = ref( const resolution: Ref<[number, number]> = ref(
props.instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]), props.instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
) )
const fullscreenSetting: Ref<boolean> = ref( const fullscreenSetting: Ref<boolean> = ref(
props.instance.force_fullscreen ?? globalSettings.force_fullscreen, props.instance.force_fullscreen ?? globalSettings.force_fullscreen,
) )
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
const editProfile: { const editProfile: {
force_fullscreen?: boolean force_fullscreen?: boolean
game_resolution?: [number, number] game_resolution?: [number, number]
} = {} } = {}
if (overrideWindowSettings.value) { if (overrideWindowSettings.value) {
editProfile.force_fullscreen = fullscreenSetting.value editProfile.force_fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) { if (!fullscreenSetting.value) {
editProfile.game_resolution = resolution.value editProfile.game_resolution = resolution.value
} }
} }
return editProfile return editProfile
}) })
watch( watch(
[overrideWindowSettings, resolution, fullscreenSetting], [overrideWindowSettings, resolution, fullscreenSetting],
async () => { async () => {
await edit(props.instance.path, editProfileObject.value) await edit(props.instance.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )
const messages = defineMessages({ const messages = defineMessages({
customWindowSettings: { customWindowSettings: {
id: 'instance.settings.tabs.window.custom-window-settings', id: 'instance.settings.tabs.window.custom-window-settings',
defaultMessage: 'Custom window settings', defaultMessage: 'Custom window settings',
}, },
fullscreen: { fullscreen: {
id: 'instance.settings.tabs.window.fullscreen', id: 'instance.settings.tabs.window.fullscreen',
defaultMessage: 'Fullscreen', defaultMessage: 'Fullscreen',
}, },
fullscreenDescription: { fullscreenDescription: {
id: 'instance.settings.tabs.window.fullscreen.description', id: 'instance.settings.tabs.window.fullscreen.description',
defaultMessage: 'Make the game start in full screen when launched (using options.txt).', defaultMessage: 'Make the game start in full screen when launched (using options.txt).',
}, },
width: { width: {
id: 'instance.settings.tabs.window.width', id: 'instance.settings.tabs.window.width',
defaultMessage: 'Width', defaultMessage: 'Width',
}, },
widthDescription: { widthDescription: {
id: 'instance.settings.tabs.window.width.description', id: 'instance.settings.tabs.window.width.description',
defaultMessage: 'The width of the game window when launched.', defaultMessage: 'The width of the game window when launched.',
}, },
enterWidth: { enterWidth: {
id: 'instance.settings.tabs.window.width.enter', id: 'instance.settings.tabs.window.width.enter',
defaultMessage: 'Enter width...', defaultMessage: 'Enter width...',
}, },
height: { height: {
id: 'instance.settings.tabs.window.height', id: 'instance.settings.tabs.window.height',
defaultMessage: 'Height', defaultMessage: 'Height',
}, },
heightDescription: { heightDescription: {
id: 'instance.settings.tabs.window.height.description', id: 'instance.settings.tabs.window.height.description',
defaultMessage: 'The height of the game window when launched.', defaultMessage: 'The height of the game window when launched.',
}, },
enterHeight: { enterHeight: {
id: 'instance.settings.tabs.window.height.enter', id: 'instance.settings.tabs.window.height.enter',
defaultMessage: 'Enter height...', defaultMessage: 'Enter height...',
}, },
}) })
</script> </script>
<template> <template>
<div> <div>
<Checkbox <Checkbox
v-model="overrideWindowSettings" v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)" :label="formatMessage(messages.customWindowSettings)"
@update:model-value=" @update:model-value="
(value) => { (value) => {
if (!value) { if (!value) {
resolution = globalSettings.game_resolution resolution = globalSettings.game_resolution
fullscreenSetting = globalSettings.force_fullscreen fullscreenSetting = globalSettings.force_fullscreen
} }
} }
" "
/> />
<div class="mt-2 flex items-center gap-4 justify-between"> <div class="mt-2 flex items-center gap-4 justify-between">
<div> <div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast"> <h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.fullscreen) }} {{ formatMessage(messages.fullscreen) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.fullscreenDescription) }} {{ formatMessage(messages.fullscreenDescription) }}
</p> </p>
</div> </div>
<Toggle <Toggle
id="fullscreen" id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen" :model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:disabled="!overrideWindowSettings" :disabled="!overrideWindowSettings"
@update:model-value=" @update:model-value="
(e) => { (e) => {
fullscreenSetting = e fullscreenSetting = e
} }
" "
/> />
</div> </div>
<div class="mt-4 flex items-center gap-4 justify-between"> <div class="mt-4 flex items-center gap-4 justify-between">
<div> <div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast"> <h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.width) }} {{ formatMessage(messages.width) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.widthDescription) }} {{ formatMessage(messages.widthDescription) }}
</p> </p>
</div> </div>
<input <input
id="width" id="width"
v-model="resolution[0]" v-model="resolution[0]"
autocomplete="off" autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting" :disabled="!overrideWindowSettings || fullscreenSetting"
type="number" type="number"
:placeholder="formatMessage(messages.enterWidth)" :placeholder="formatMessage(messages.enterWidth)"
/> />
</div> </div>
<div class="mt-4 flex items-center gap-4 justify-between"> <div class="mt-4 flex items-center gap-4 justify-between">
<div> <div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast"> <h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.height) }} {{ formatMessage(messages.height) }}
</h2> </h2>
<p class="m-0"> <p class="m-0">
{{ formatMessage(messages.heightDescription) }} {{ formatMessage(messages.heightDescription) }}
</p> </p>
</div> </div>
<input <input
id="height" id="height"
v-model="resolution[1]" v-model="resolution[1]"
autocomplete="off" autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting" :disabled="!overrideWindowSettings || fullscreenSetting"
type="number" type="number"
:placeholder="formatMessage(messages.enterHeight)" :placeholder="formatMessage(messages.enterHeight)"
/> />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,28 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ReportIcon, CoffeeIcon,
AstralRinthLogo, GameIcon,
ShieldIcon, GaugeIcon,
SettingsIcon, AstralRinthLogo,
GaugeIcon, DownloadIcon,
PaintbrushIcon, SpinnerIcon,
GameIcon, PaintbrushIcon,
CoffeeIcon, ReportIcon,
SettingsIcon,
ShieldIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { TabbedModal } from '@modrinth/ui' import { TabbedModal } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { useVIntl, defineMessage } from '@vintl/vintl'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/plugin-os' import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { useTheming } from '@/store/state' import { defineMessage, useVIntl } from '@vintl/vintl'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue' import { computed, ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.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 PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings.ts'
// [AR] Imports
import { installState, getRemote, updateState } from '@/helpers/update.js'
const updateModalView = ref(null)
const updateRequestFailView = ref(null)
const initUpdateModal = async () => {
updateModalView.value.show()
}
const initDownload = async () => {
updateModalView.value.hide()
const result = await getRemote(true);
if (!result) {
updateRequestFailView.value.show()
}
}
import { useTheming } from '@/store/state'
const themeStore = useTheming() const themeStore = useTheming()
@@ -31,66 +51,66 @@ const { formatMessage } = useVIntl()
const devModeCounter = ref(0) const devModeCounter = ref(0)
const developerModeEnabled = defineMessage({ const developerModeEnabled = defineMessage({
id: 'app.settings.developer-mode-enabled', id: 'app.settings.developer-mode-enabled',
defaultMessage: 'Developer mode enabled.', defaultMessage: 'Developer mode enabled.',
}) })
const tabs = [ const tabs = [
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.appearance', id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance', defaultMessage: 'Appearance',
}), }),
icon: PaintbrushIcon, icon: PaintbrushIcon,
content: AppearanceSettings, content: AppearanceSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.privacy', id: 'app.settings.tabs.privacy',
defaultMessage: 'Privacy', defaultMessage: 'Privacy',
}), }),
icon: ShieldIcon, icon: ShieldIcon,
content: PrivacySettings, content: PrivacySettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.java-installations', id: 'app.settings.tabs.java-installations',
defaultMessage: 'Java installations', defaultMessage: 'Java installations',
}), }),
icon: CoffeeIcon, icon: CoffeeIcon,
content: JavaSettings, content: JavaSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.default-instance-options', id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default instance options', defaultMessage: 'Default instance options',
}), }),
icon: GameIcon, icon: GameIcon,
content: DefaultInstanceSettings, content: DefaultInstanceSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.resource-management', id: 'app.settings.tabs.resource-management',
defaultMessage: 'Resource management', defaultMessage: 'Resource management',
}), }),
icon: GaugeIcon, icon: GaugeIcon,
content: ResourceManagementSettings, content: ResourceManagementSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'app.settings.tabs.feature-flags', id: 'app.settings.tabs.feature-flags',
defaultMessage: 'Feature flags', defaultMessage: 'Feature flags',
}), }),
icon: ReportIcon, icon: ReportIcon,
content: FeatureFlagSettings, content: FeatureFlagSettings,
developerOnly: true, developerOnly: true,
}, },
] ]
const modal = ref() const modal = ref()
function show() { function show() {
modal.value.show() modal.value.show()
} }
const isOpen = computed(() => modal.value?.isOpen) const isOpen = computed(() => modal.value?.isOpen)
@@ -103,59 +123,133 @@ const osVersion = getOsVersion()
const settings = ref(await get()) const settings = ref(await get())
watch( watch(
settings, settings,
async () => { async () => {
await set(settings.value) await set(settings.value)
}, },
{ deep: true }, { deep: true },
) )
function devModeCount() { function devModeCount() {
devModeCounter.value++ devModeCounter.value++
if (devModeCounter.value > 5) { if (devModeCounter.value > 5) {
themeStore.devMode = !themeStore.devMode themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0 devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) { if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) {
modal.value.setTab(0) modal.value.setTab(0)
} }
} }
} }
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <ModalWrapper ref="modal">
<template #title> <template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast"> <span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Settings <SettingsIcon /> Settings
</span> </span>
</template> </template>
<TabbedModal :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)"> <TabbedModal :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<template #footer> <template #footer>
<div class="mt-auto text-secondary text-sm"> <div class="mt-auto text-secondary text-sm">
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2"> <p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }} {{ formatMessage(developerModeEnabled) }}
</p> </p>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation" class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }" :class="{
@click="devModeCount" 'text-brand': themeStore.devMode,
> 'text-secondary': !themeStore.devMode,
<AstralRinthLogo class="w-6 h-6" /> }"
</button> @click="devModeCount"
<div> >
<p class="m-0">AstralRinth App {{ version }}</p> <AstralRinthLogo class="w-6 h-6" />
<p class="m-0"> </button>
<span v-if="osPlatform === 'macos'">MacOS</span> <div>
<span v-else class="capitalize">{{ osPlatform }}</span> <p class="m-0">AstralRinth App {{ version }}</p>
{{ osVersion }} <p class="m-0">
</p> <span v-if="osPlatform === 'macos'">MacOS</span>
</div> <span v-else class="capitalize">{{ osPlatform }}</span>
</div> {{ osVersion }}
</div> </p>
</template> </div>
</TabbedModal> <div v-if="updateState" class="w-8 h-8 cursor-pointer hover:brightness-75 neon-icon pulse">
</ModalWrapper> <template v-if="installState">
<SpinnerIcon class="size-6 animate-spin" v-tooltip.bottom="'Installing in process...'" />
</template>
<template v-else>
<DownloadIcon class="size-6" v-tooltip.bottom="'View update info'" @click="!installState && (initUpdateModal(), getRemote(false))" />
</template>
</div>
</div>
</div>
</template>
</TabbedModal>
<!-- [AR] Feature -->
<ModalWrapper ref="updateModalView" :has-to-type="false" header="Request to update the AstralRinth launcher">
<div class="space-y-4">
<div class="space-y-2">
<p>The new version of the AstralRinth launcher is available.</p>
<p>Your version is outdated. We recommend that you update to the latest version.</p>
<p><strong> Warning </strong></p>
<p>
Before updating, make sure that you have saved all running instances and made a backup copy of the instances
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of
your files, so you should always make copies of them and keep them in a safe place.
</p>
</div>
<div class="text-sm text-secondary space-y-1">
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank"
rel="noopener noreferrer"><strong>Source:</strong> Git Astralium</a>
<p>
<strong>Version on remote server:</strong>
<span id="releaseData" class="neon-text"></span>
</p>
<p>
<strong>Version on local device:</strong>
<span class="neon-text">v{{ version }}</span>
</p>
</div>
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
<Button class="bordered" @click="updateModalView.hide()">Cancel</Button>
<Button class="bordered" @click="initDownload()">Download file</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="updateRequestFailView" :has-to-type="false" header="Failed to request a file from the server :(">
<div class="space-y-4">
<div class="space-y-2">
<p><strong>Error occurred</strong></p>
<p>Unfortunately, the program was unable to download the file from our servers.</p>
<p>
Please try downloading it yourself from
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">Git
Astralium</a>
if there are any updates available.
</p>
</div>
<div class="text-sm text-secondary">
<p>
<strong>Local AstralRinth:</strong>
<span class="neon-text">v{{ version }}</span>
</p>
</div>
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
<Button class="bordered" @click="updateRequestFailView.hide()">Close</Button>
</div>
</div>
</ModalWrapper>
</ModalWrapper>
</template> </template>
<style lang="scss" scoped>
@import '../../../../../../packages/assets/styles/neon-icon.scss';
@import '../../../../../../packages/assets/styles/neon-button.scss';
@import '../../../../../../packages/assets/styles/neon-text.scss';
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue.
</p>
</ModalWrapper>
</template>

View File

@@ -1,89 +1,91 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { ConfirmModal } from '@modrinth/ui' import { ConfirmModal } from '@modrinth/ui'
import { ref } from 'vue'
import { useTheming } from '@/store/theme.js' // import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
const props = defineProps({ const props = defineProps({
confirmationText: { confirmationText: {
type: String, type: String,
default: '', default: '',
}, },
hasToType: { hasToType: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
title: { title: {
type: String, type: String,
default: 'No title defined', default: 'No title defined',
required: true, required: true,
}, },
description: { description: {
type: String, type: String,
default: 'No description defined', default: 'No description defined',
required: true, required: true,
}, },
proceedIcon: { proceedIcon: {
type: Object, type: Object,
default: undefined, default: undefined,
}, },
proceedLabel: { proceedLabel: {
type: String, type: String,
default: 'Proceed', default: 'Proceed',
}, },
danger: { danger: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
// showAdOnClose: { // showAdOnClose: {
// type: Boolean, // type: Boolean,
// default: true, // default: true,
// }, // },
markdown: { markdown: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}) })
const emit = defineEmits(['proceed']) const emit = defineEmits(['proceed'])
const modal = ref(null) const modal = ref(null)
defineExpose({ defineExpose({
show: () => { show: () => {
modal.value.show() // hide_ads_window()
}, modal.value.show()
hide: () => { },
// onModalHide() hide: () => {
modal.value.hide() onModalHide()
}, modal.value.hide()
},
}) })
// function onModalHide() { // function onModalHide() {
// if (props.showAdOnClose) { // if (props.showAdOnClose) {
// show_ads_window() // show_ads_window()
// } // }
// } // }
function proceed() { function proceed() {
emit('proceed') emit('proceed')
} }
</script> </script>
<template> <template>
<ConfirmModal <ConfirmModal
ref="modal" ref="modal"
:confirmation-text="confirmationText" :confirmation-text="confirmationText"
:has-to-type="hasToType" :has-to-type="hasToType"
:title="title" :title="title"
:description="description" :description="description"
:proceed-icon="proceedIcon" :proceed-icon="proceedIcon"
:proceed-label="proceedLabel" :proceed-label="proceedLabel"
:on-hide="onModalHide" :on-hide="onModalHide"
:noblur="!themeStore.advancedRendering" :noblur="!themeStore.advancedRendering"
:danger="danger" :danger="danger"
:markdown="markdown" :markdown="markdown"
@proceed="proceed" @proceed="proceed"
/> />
</template> </template>

View File

@@ -2,19 +2,20 @@
import { ChevronRightIcon } from '@modrinth/assets' import { ChevronRightIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui' import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
defineProps<{ defineProps<{
instance: GameInstance instance: GameInstance
}>() }>()
</script> </script>
<template> <template>
<span class="flex items-center gap-2 text-lg font-semibold text-primary"> <span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar <Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px" size="24px"
:tint-by="instance.path" :tint-by="instance.path"
/> />
{{ instance.name }} <ChevronRightIcon /> {{ instance.name }} <ChevronRightIcon />
</span> </span>
</template> </template>

View File

@@ -1,22 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ChevronRightIcon, ChevronRightIcon,
CoffeeIcon, CodeIcon,
InfoIcon, CoffeeIcon,
WrenchIcon, InfoIcon,
MonitorIcon, MonitorIcon,
CodeIcon, WrenchIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, TabbedModal, type TabbedModalTab } from '@modrinth/ui' import { Avatar, TabbedModal, type TabbedModalTab } from '@modrinth/ui'
import { ref } from 'vue'
import { defineMessage, useVIntl } from '@vintl/vintl'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { ref } from 'vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue' import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue' import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue' import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { InstanceSettingsTabProps } from '../../../helpers/types' import type { InstanceSettingsTabProps } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -24,75 +26,75 @@ const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>() const props = defineProps<InstanceSettingsTabProps>()
const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [ const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.general', id: 'instance.settings.tabs.general',
defaultMessage: 'General', defaultMessage: 'General',
}), }),
icon: InfoIcon, icon: InfoIcon,
content: GeneralSettings, content: GeneralSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.installation', id: 'instance.settings.tabs.installation',
defaultMessage: 'Installation', defaultMessage: 'Installation',
}), }),
icon: WrenchIcon, icon: WrenchIcon,
content: InstallationSettings, content: InstallationSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.window', id: 'instance.settings.tabs.window',
defaultMessage: 'Window', defaultMessage: 'Window',
}), }),
icon: MonitorIcon, icon: MonitorIcon,
content: WindowSettings, content: WindowSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.java', id: 'instance.settings.tabs.java',
defaultMessage: 'Java and memory', defaultMessage: 'Java and memory',
}), }),
icon: CoffeeIcon, icon: CoffeeIcon,
content: JavaSettings, content: JavaSettings,
}, },
{ {
name: defineMessage({ name: defineMessage({
id: 'instance.settings.tabs.hooks', id: 'instance.settings.tabs.hooks',
defaultMessage: 'Launch hooks', defaultMessage: 'Launch hooks',
}), }),
icon: CodeIcon, icon: CodeIcon,
content: HooksSettings, content: HooksSettings,
}, },
] ]
const modal = ref() const modal = ref()
function show() { function show() {
modal.value.show() modal.value.show()
} }
defineExpose({ show }) defineExpose({ show })
const titleMessage = defineMessage({ const titleMessage = defineMessage({
id: 'instance.settings.title', id: 'instance.settings.title',
defaultMessage: 'Settings', defaultMessage: 'Settings',
}) })
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <ModalWrapper ref="modal">
<template #title> <template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary"> <span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar <Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px" size="24px"
:tint-by="props.instance.path" :tint-by="props.instance.path"
/> />
{{ instance.name }} <ChevronRightIcon /> {{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{ formatMessage(titleMessage) }}</span> <span class="font-extrabold text-contrast">{{ formatMessage(titleMessage) }}</span>
</span> </span>
</template> </template>
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" /> <TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -1,57 +1,58 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateRef } from 'vue'
import { NewModal as Modal } from '@modrinth/ui' import { NewModal as Modal } from '@modrinth/ui'
// import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { useTemplateRef } from 'vue'
import { useTheming } from '@/store/theme.js'
// import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
const props = defineProps({ const props = defineProps({
header: { header: {
type: String, type: String,
default: null, default: null,
}, },
closable: { closable: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
onHide: { onHide: {
type: Function, type: Function,
default() { default() {
return () => { } return () => {}
}, },
}, },
// showAdOnClose: { // showAdOnClose: {
// type: Boolean, // type: Boolean,
// default: true, // default: true,
// }, // },
}) })
const modal = useTemplateRef('modal') const modal = useTemplateRef('modal')
defineExpose({ defineExpose({
show: (e: MouseEvent) => { show: (e: MouseEvent) => {
// hide_ads_window() // hide_ads_window()
modal.value?.show(e) modal.value?.show(e)
}, },
hide: () => { hide: () => {
onModalHide() onModalHide()
modal.value?.hide() modal.value?.hide()
}, },
}) })
function onModalHide() { function onModalHide() {
// if (props.showAdOnClose) { // if (props.showAdOnClose) {
// show_ads_window() // show_ads_window()
// } // }
props.onHide?.() props.onHide?.()
} }
</script> </script>
<template> <template>
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide"> <Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
<template #title> <template #title>
<slot name="title" /> <slot name="title" />
</template> </template>
<slot /> <slot />
</Modal> </Modal>
</template> </template>

View File

@@ -1,48 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { ShareModal } from '@modrinth/ui' import { ShareModal } from '@modrinth/ui'
import { ref } from 'vue'
import { useTheming } from '@/store/theme.js' // import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
defineProps({ defineProps({
header: { header: {
type: String, type: String,
default: 'Share', default: 'Share',
}, },
shareTitle: { shareTitle: {
type: String, type: String,
default: 'Modrinth', default: 'Modrinth',
}, },
shareText: { shareText: {
type: String, type: String,
default: null, default: null,
}, },
link: { link: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
openInNewTab: { openInNewTab: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}) })
const modal = ref(null) const modal = ref(null)
defineExpose({ defineExpose({
show: (passedContent) => { show: (passedContent) => {
modal.value.show(passedContent) // hide_ads_window()
}, modal.value.show(passedContent)
hide: () => { },
onModalHide() hide: () => {
modal.value.hide() onModalHide()
}, modal.value.hide()
},
}) })
// function onModalHide() {
// show_ads_window()
// }
</script> </script>
<template> <template>
<ShareModal ref="modal" :header="header" :share-title="shareTitle" :share-text="shareText" :link="link" <ShareModal
:open-in-new-tab="openInNewTab" :on-hide="onModalHide" :noblur="!themeStore.advancedRendering" /> ref="modal"
:header="header"
:share-title="shareTitle"
:share-text="shareText"
:link="link"
:open-in-new-tab="openInNewTab"
:on-hide="onModalHide"
:noblur="!themeStore.advancedRendering"
/>
</template> </template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui' import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils' import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme } from '@/store/theme.ts' import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
@@ -12,119 +13,119 @@ const os = ref(await getOS())
const settings = ref(await get()) const settings = ref(await get())
watch( watch(
settings, settings,
async () => { async () => {
await set(settings.value) await set(settings.value)
}, },
{ deep: true }, { deep: true },
) )
</script> </script>
<template> <template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Color theme</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Color theme</h2>
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p> <p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
<ThemeSelector <ThemeSelector
:update-color-theme=" :update-color-theme="
(theme: ColorTheme) => { (theme: ColorTheme) => {
themeStore.setThemeState(theme) themeStore.setThemeState(theme)
settings.theme = theme settings.theme = theme
} }
" "
:current-theme="settings.theme" :current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()" :theme-options="themeStore.getThemeOptions()"
system-theme-color="system" system-theme-color="system"
/> />
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Advanced rendering</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Advanced rendering</h2>
<p class="m-0 mt-1"> <p class="m-0 mt-1">
Enables advanced rendering such as blur effects that may cause performance issues without Enables advanced rendering such as blur effects that may cause performance issues without
hardware-accelerated rendering. hardware-accelerated rendering.
</p> </p>
</div> </div>
<Toggle <Toggle
id="advanced-rendering" id="advanced-rendering"
:model-value="themeStore.advancedRendering" :model-value="themeStore.advancedRendering"
@update:model-value=" @update:model-value="
(e) => { (e) => {
themeStore.advancedRendering = e themeStore.advancedRendering = e
settings.advanced_rendering = themeStore.advancedRendering settings.advanced_rendering = themeStore.advancedRendering
} }
" "
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p> <p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
</div> </div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" /> <Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div> </div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4"> <div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p> <p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div> </div>
<Toggle id="native-decorations" v-model="settings.native_decorations" /> <Toggle id="native-decorations" v-model="settings.native_decorations" />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p> <p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div> </div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" /> <Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Default landing page</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Default landing page</h2>
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p> <p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
</div> </div>
<TeleportDropdownMenu <TeleportDropdownMenu
id="opening-page" id="opening-page"
v-model="settings.default_page" v-model="settings.default_page"
name="Opening page dropdown" name="Opening page dropdown"
class="w-40" class="w-40"
:options="['Home', 'Library']" :options="['Home', 'Library']"
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p> <p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div> </div>
<Toggle <Toggle
:model-value="themeStore.getFeatureFlag('worlds_in_home')" :model-value="themeStore.getFeatureFlag('worlds_in_home')"
@update:model-value=" @update:model-value="
() => { () => {
const newValue = !themeStore.getFeatureFlag('worlds_in_home') const newValue = !themeStore.getFeatureFlag('worlds_in_home')
themeStore.featureFlags['worlds_in_home'] = newValue themeStore.featureFlags['worlds_in_home'] = newValue
settings.feature_flags['worlds_in_home'] = newValue settings.feature_flags['worlds_in_home'] = newValue
} }
" "
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
<p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p> <p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p>
</div> </div>
<Toggle <Toggle
id="toggle-sidebar" id="toggle-sidebar"
:model-value="settings.toggle_sidebar" :model-value="settings.toggle_sidebar"
@update:model-value=" @update:model-value="
(e) => { (e) => {
settings.toggle_sidebar = e settings.toggle_sidebar = e
themeStore.toggleSidebar = settings.toggle_sidebar themeStore.toggleSidebar = settings.toggle_sidebar
} }
" "
/> />
</div> </div>
</template> </template>

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { get, set } from '@/helpers/settings.ts' import { injectNotificationManager, Slider, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications' import useMemorySlider from '@/composables/useMemorySlider'
import { Slider, Toggle } from '@modrinth/ui' import { get, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const fetchSettings = await get() const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ') fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
@@ -11,162 +13,167 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
const settings = ref(fetchSettings) const settings = ref(fetchSettings)
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024)) const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch( watch(
settings, settings,
async () => { async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value)) const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean) setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = setSettings.envVars setSettings.custom_env_vars = setSettings.envVars
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
.map((x) => x.split('=').filter(Boolean)) .map((x) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) { if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null setSettings.hooks.pre_launch = null
} }
if (!setSettings.hooks.wrapper) { if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null setSettings.hooks.wrapper = null
} }
if (!setSettings.hooks.post_exit) { if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null setSettings.hooks.post_exit = null
} }
if (!setSettings.custom_dir) { if (!setSettings.custom_dir) {
setSettings.custom_dir = null setSettings.custom_dir = null
} }
await set(setSettings) await set(setSettings)
}, },
{ deep: true }, { deep: true },
) )
</script> </script>
<template> <template>
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Window size</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Window size</h2>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Fullscreen</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Fullscreen</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Overwrites the options.txt file to start in full screen when launched. Overwrites the options.txt file to start in full screen when launched.
</p> </p>
</div> </div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" /> <Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Width</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Width</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The width of the game window when launched. The width of the game window when launched.
</p> </p>
</div> </div>
<input <input
id="width" id="width"
v-model="settings.game_resolution[0]" v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen" :disabled="settings.force_fullscreen"
autocomplete="off" autocomplete="off"
type="number" type="number"
placeholder="Enter width..." placeholder="Enter width..."
/> />
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Height</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Height</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The height of the game window when launched. The height of the game window when launched.
</p> </p>
</div> </div>
<input <input
id="height" id="height"
v-model="settings.game_resolution[1]" v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen" :disabled="settings.force_fullscreen"
autocomplete="off" autocomplete="off"
type="number" type="number"
class="input" class="input"
placeholder="Enter height..." placeholder="Enter height..."
/> />
</div> </div>
<hr class="mt-4 bg-button-border border-none h-[1px]" /> <hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Memory allocated</h2> <h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Memory allocated</h2>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p> <p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
<Slider <Slider
id="max-memory" id="max-memory"
v-model="settings.memory.maximum" v-model="settings.memory.maximum"
:min="512" :min="512"
:max="maxMemory" :max="maxMemory"
:step="64" :step="64"
unit="MB" :snap-points="snapPoints"
/> :snap-range="512"
unit="MB"
/>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Java arguments</h2> <h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Java arguments</h2>
<input <input
id="java-args" id="java-args"
v-model="settings.launchArgs" v-model="settings.launchArgs"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter java arguments..." placeholder="Enter java arguments..."
class="w-full" class="w-full"
/> />
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Environmental variables</h2> <h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Environmental variables</h2>
<input <input
id="env-vars" id="env-vars"
v-model="settings.envVars" v-model="settings.envVars"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter environmental variables..." placeholder="Enter environmental variables..."
class="w-full" class="w-full"
/> />
<hr class="mt-4 bg-button-border border-none h-[1px]" /> <hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Hooks</h2> <h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Hooks</h2>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Pre launch</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Pre launch</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran before the instance is launched.</p> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran before the instance is launched.</p>
<input <input
id="pre-launch" id="pre-launch"
v-model="settings.hooks.pre_launch" v-model="settings.hooks.pre_launch"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter pre-launch command..." placeholder="Enter pre-launch command..."
class="w-full" class="w-full"
/> />
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Wrapper</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Wrapper</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Wrapper command for launching Minecraft. Wrapper command for launching Minecraft.
</p> </p>
<input <input
id="wrapper" id="wrapper"
v-model="settings.hooks.wrapper" v-model="settings.hooks.wrapper"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter wrapper command..." placeholder="Enter wrapper command..."
class="w-full" class="w-full"
/> />
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Post exit</h3> <h3 class="mt-2 m-0 text-base font-extrabold text-primary">Post exit</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran after the game closes.</p> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran after the game closes.</p>
<input <input
id="post-exit" id="post-exit"
v-model="settings.hooks.post_exit" v-model="settings.hooks.post_exit"
autocomplete="off" autocomplete="off"
type="text" type="text"
placeholder="Enter post-exit command..." placeholder="Enter post-exit command..."
class="w-full" class="w-full"
/> />
</div> </div>
</template> </template>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Toggle } from '@modrinth/ui' import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts' import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
@@ -11,30 +12,30 @@ const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS)) const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
function setFeatureFlag(key: string, value: boolean) { function setFeatureFlag(key: string, value: boolean) {
themeStore.featureFlags[key] = value themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value settings.value.feature_flags[key] = value
} }
watch( watch(
settings, settings,
async () => { async () => {
await setSettings(settings.value) await setSettings(settings.value)
}, },
{ deep: true }, { deep: true },
) )
</script> </script>
<template> <template>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between"> <div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize"> <h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }} {{ option.replaceAll('_', ' ') }}
</h2> </h2>
</div> </div>
<Toggle <Toggle
id="advanced-rendering" id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)" :model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))" @update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/> />
</div> </div>
</template> </template>

View File

@@ -1,32 +1,35 @@
<script setup> <script setup>
import { injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import JavaSelector from '@/components/ui/JavaSelector.vue' import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
const { handleError } = injectNotificationManager()
const javaVersions = ref(await get_java_versions().catch(handleError)) const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) { async function updateJavaVersion(version) {
if (version?.path === '') { if (version?.path === '') {
version.path = undefined version.path = undefined
} }
if (version?.path) { if (version?.path) {
version.path = version.path.replace('java.exe', 'javaw.exe') version.path = version.path.replace('java.exe', 'javaw.exe')
} }
await set_java_version(version).catch(handleError) await set_java_version(version).catch(handleError)
} }
</script> </script>
<template> <template>
<div v-for="(javaVersion, index) in [21, 17, 8]" :key="`java-${javaVersion}`"> <div v-for="(javaVersion, index) in [21, 17, 8]" :key="`java-${javaVersion}`">
<h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }"> <h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location Java {{ javaVersion }} location
</h2> </h2>
<JavaSelector <JavaSelector
:id="'java-selector-' + javaVersion" :id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]" v-model="javaVersions[javaVersion]"
:version="javaVersion" :version="javaVersion"
@update:model-value="updateJavaVersion" @update:model-value="updateJavaVersion"
/> />
</div> </div>
</template> </template>

View File

@@ -1,64 +1,65 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { Toggle } from '@modrinth/ui' import { Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics' import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
import { get, set } from '@/helpers/settings.ts'
const settings = ref(await get()) const settings = ref(await get())
watch( watch(
settings, settings,
async () => { async () => {
if (settings.value.telemetry) { if (settings.value.telemetry) {
optInAnalytics() optInAnalytics()
} else { } else {
optOutAnalytics() optOutAnalytics()
} }
await set(settings.value) await set(settings.value)
}, },
{ deep: true }, { deep: true },
) )
</script> </script>
<template> <template>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Personalized ads</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Personalized ads</h2>
<p class="m-0 text-sm"> <p class="m-0 text-sm">
(Hard disabled by AR) Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
option, you opt out and ads will no longer be shown based on your interests. option, you opt out and ads will no longer be shown based on your interests.
</p> </p>
</div> </div>
<!-- AstralRinth disabled element by default --> <!-- [AR] Patch. Disabled element by default -->
<Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" /> <Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" />
</div> </div>
<div class="mt-4 flex items-center justify-between gap-4"> <div class="mt-4 flex items-center justify-between gap-4">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Telemetry</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Telemetry</h2>
<p class="m-0 text-sm"> <p class="m-0 text-sm">
(Hard disabled by AR) • Modrinth collects anonymized analytics and usage data to improve our user experience and 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 customize your experience. By disabling this option, you opt out and your data will no
longer be collected. longer be collected.
</p> </p>
</div> </div>
<!-- AstralRinth disabled element by default --> <!-- [AR] Patch. Disabled element by default -->
<Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" /> <Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" />
</div> </div>
<div class="mt-4 flex items-center justify-between gap-4"> <div class="mt-4 flex items-center justify-between gap-4">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Discord RPC</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Discord RPC</h2>
<p class="m-0 text-sm"> <p class="m-0 text-sm">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no 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. longer show up as a game or app you are using on your Discord profile.
</p> </p>
<p class="m-0 mt-2 text-sm"> <p class="m-0 mt-2 text-sm">
Note: This will not prevent any instance-specific Discord Rich Presence integrations, such Note: This will not prevent any instance-specific Discord Rich Presence integrations, such
as those added by mods. (app restart required to take effect) as those added by mods. (app restart required to take effect)
</p> </p>
</div> </div>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" /> <Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
</div> </div>
</template> </template>

View File

@@ -1,117 +1,118 @@
<script setup> <script setup>
import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { purge_cache_types } from '@/helpers/cache.js'
import { handleError } from '@/store/notifications.js'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets' import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import { Button, injectNotificationManager, Slider } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog' 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'
const { handleError } = injectNotificationManager()
const settings = ref(await get()) const settings = ref(await get())
watch( watch(
settings, settings,
async () => { async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value)) const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) { if (!setSettings.custom_dir) {
setSettings.custom_dir = null setSettings.custom_dir = null
} }
await set(setSettings) await set(setSettings)
}, },
{ deep: true }, { deep: true },
) )
async function purgeCache() { async function purgeCache() {
await purge_cache_types([ await purge_cache_types([
'project', 'project',
'version', 'version',
'user', 'user',
'team', 'team',
'organization', 'organization',
'loader_manifest', 'loader_manifest',
'minecraft_manifest', 'minecraft_manifest',
'categories', 'categories',
'report_types', 'report_types',
'loaders', 'loaders',
'game_versions', 'game_versions',
'donation_platforms', 'donation_platforms',
'file_update', 'file_update',
'search_results', 'search_results',
]).catch(handleError) ]).catch(handleError)
} }
async function findLauncherDir() { async function findLauncherDir() {
const newDir = await open({ const newDir = await open({
multiple: false, multiple: false,
directory: true, directory: true,
title: 'Select a new app directory', title: 'Select a new app directory',
}) })
if (newDir) { if (newDir) {
settings.value.custom_dir = newDir settings.value.custom_dir = newDir
} }
} }
</script> </script>
<template> <template>
<h2 class="m-0 text-lg font-extrabold text-contrast">App directory</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">App directory</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher. restarting the launcher.
</p> </p>
<div class="m-1 my-2"> <div class="m-1 my-2">
<div class="iconified-input w-full"> <div class="iconified-input w-full">
<BoxIcon /> <BoxIcon />
<input id="appDir" v-model="settings.custom_dir" type="text" class="input" /> <input id="appDir" v-model="settings.custom_dir" type="text" class="input" />
<Button class="r-btn" @click="findLauncherDir"> <Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon /> <FolderSearchIcon />
</Button> </Button>
</div> </div>
</div> </div>
<div> <div>
<ConfirmModalWrapper <ConfirmModalWrapper
ref="purgeCacheConfirmModal" ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?" 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." description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false" :has-to-type="false"
proceed-label="Purge cache" proceed-label="Purge cache"
:show-ad-on-close="false" :show-ad-on-close="false"
@proceed="purgeCache" @proceed="purgeCache"
/> />
<h2 class="m-0 text-lg font-extrabold text-contrast">App cache</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">App cache</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the 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. app to reload data. This may slow down the app temporarily.
</p> </p>
</div> </div>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()"> <button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon /> <TrashIcon />
Purge cache Purge cache
</button> </button>
<h2 class="m-0 text-lg font-extrabold text-contrast mt-4">Maximum concurrent downloads</h2> <h2 class="m-0 text-lg font-extrabold text-contrast mt-4">Maximum concurrent downloads</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower 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) value if you have a poor internet connection. (app restart required to take effect)
</p> </p>
<Slider <Slider
id="max-downloads" id="max-downloads"
v-model="settings.max_concurrent_downloads" v-model="settings.max_concurrent_downloads"
:min="1" :min="1"
:max="10" :max="10"
:step="1" :step="1"
/> />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Maximum concurrent writes</h2> <h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Maximum concurrent writes</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary"> <p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower 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) value if you are frequently getting I/O errors. (app restart required to take effect)
</p> </p>
<Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" /> <Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" />
</template> </template>

View File

@@ -1,135 +1,139 @@
<template> <template>
<UploadSkinModal ref="uploadModal" /> <UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState"> <ModalWrapper ref="modal" @on-hide="resetState">
<template #title> <template #title>
<span class="text-lg font-extrabold text-contrast"> <span class="text-lg font-extrabold text-contrast">
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }} {{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
</span> </span>
</template> </template>
<div class="flex flex-col md:flex-row gap-6"> <div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative"> <div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0"> <div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer <SkinPreviewRenderer
:variant="variant" :variant="variant"
:texture-src="previewSkin || ''" :texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture" :cape-src="selectedCapeTexture"
:scale="1.4" :scale="1.4"
:fov="50" :fov="50"
:initial-rotation="Math.PI / 8" :initial-rotation="Math.PI / 8"
class="h-full w-full" class="h-full w-full"
/> />
</div> </div>
</div> </div>
<div class="flex flex-col gap-4 w-full min-h-[20rem]"> <div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section> <section>
<h2 class="text-base font-semibold mb-2">Texture</h2> <h2 class="text-base font-semibold mb-2">Texture</h2>
<Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button> <Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button>
</section> </section>
<section> <section>
<h2 class="text-base font-semibold mb-2">Arm style</h2> <h2 class="text-base font-semibold mb-2">Arm style</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']"> <RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<template #default="{ item }"> <template #default="{ item }">
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }} {{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
</template> </template>
</RadioButtons> </RadioButtons>
</section> </section>
<section> <section>
<h2 class="text-base font-semibold mb-2">Cape</h2> <h2 class="text-base font-semibold mb-2">Cape</h2>
<div class="flex gap-2"> <div class="flex gap-2">
<CapeButton <CapeButton
v-if="defaultCape" v-if="defaultCape"
:id="defaultCape.id" :id="defaultCape.id"
:texture="defaultCape.texture" :texture="defaultCape.texture"
:name="undefined" :name="undefined"
:selected="!selectedCape" :selected="!selectedCape"
faded faded
@select="selectCape(undefined)" @select="selectCape(undefined)"
> >
<span>Use default cape</span> <span>Use default cape</span>
</CapeButton> </CapeButton>
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)"> <CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
<span>Use default cape</span> <span>Use default cape</span>
</CapeLikeTextButton> </CapeLikeTextButton>
<CapeButton <CapeButton
v-for="cape in visibleCapeList" v-for="cape in visibleCapeList"
:id="cape.id" :id="cape.id"
:key="cape.id" :key="cape.id"
:texture="cape.texture" :texture="cape.texture"
:name="cape.name || 'Cape'" :name="cape.name || 'Cape'"
:selected="selectedCape?.id === cape.id" :selected="selectedCape?.id === cape.id"
@select="selectCape(cape)" @select="selectCape(cape)"
/> />
<CapeLikeTextButton <CapeLikeTextButton
v-if="(capes?.length ?? 0) > 2" v-if="(capes?.length ?? 0) > 2"
tooltip="View more capes" tooltip="View more capes"
@mouseup="openSelectCapeModal" @mouseup="openSelectCapeModal"
> >
<template #icon><ChevronRightIcon /></template> <template #icon><ChevronRightIcon /></template>
<span>More</span> <span>More</span>
</CapeLikeTextButton> </CapeLikeTextButton>
</div> </div>
</section> </section>
</div> </div>
</div> </div>
<div class="flex gap-2 mt-12"> <div class="flex gap-2 mt-12">
<ButtonStyled color="brand" :disabled="disableSave || isSaving"> <ButtonStyled color="brand" :disabled="disableSave || isSaving">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save"> <button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" /> <SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" /> <CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else /> <SaveIcon v-else />
{{ mode === 'new' ? 'Add skin' : 'Save skin' }} {{ mode === 'new' ? 'Add skin' : 'Save skin' }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button> <Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
</div> </div>
</ModalWrapper> </ModalWrapper>
<SelectCapeModal <SelectCapeModal
ref="selectCapeModal" ref="selectCapeModal"
:capes="capes || []" :capes="capes || []"
@select="handleCapeSelected" @select="handleCapeSelected"
@cancel="handleCapeCancel" @cancel="handleCapeCancel"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, useTemplateRef } from 'vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import { import {
SkinPreviewRenderer, CheckIcon,
Button, ChevronRightIcon,
RadioButtons, SaveIcon,
CapeButton, SpinnerIcon,
CapeLikeTextButton, UploadIcon,
ButtonStyled, XIcon,
} from '@modrinth/ui'
import {
add_and_equip_custom_skin,
remove_custom_skin,
unequip_skin,
type Skin,
type Cape,
type SkinModel,
get_normalized_skin_texture,
} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import {
UploadIcon,
CheckIcon,
SaveIcon,
XIcon,
ChevronRightIcon,
SpinnerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import {
Button,
ButtonStyled,
CapeButton,
CapeLikeTextButton,
injectNotificationManager,
RadioButtons,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue' import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import {
add_and_equip_custom_skin,
type Cape,
determineModelType,
get_normalized_skin_texture,
remove_custom_skin,
type Skin,
type SkinModel,
unequip_skin,
} from '@/helpers/skins.ts'
const { handleError } = injectNotificationManager()
const modal = useTemplateRef('modal') const modal = useTemplateRef('modal')
const selectCapeModal = useTemplateRef('selectCapeModal') const selectCapeModal = useTemplateRef('selectCapeModal')
@@ -149,264 +153,264 @@ const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const visibleCapeList = ref<Cape[]>([]) const visibleCapeList = ref<Cape[]>([])
const sortedCapes = computed(() => { const sortedCapes = computed(() => {
return [...(props.capes || [])].sort((a, b) => { return [...(props.capes || [])].sort((a, b) => {
const nameA = (a.name || '').toLowerCase() const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase() const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB) return nameA.localeCompare(nameB)
}) })
}) })
function initVisibleCapeList() { function initVisibleCapeList() {
if (!props.capes || props.capes.length === 0) { if (!props.capes || props.capes.length === 0) {
visibleCapeList.value = [] visibleCapeList.value = []
return return
} }
if (visibleCapeList.value.length === 0) { if (visibleCapeList.value.length === 0) {
if (selectedCape.value) { if (selectedCape.value) {
const otherCape = getSortedCapeExcluding(selectedCape.value.id) const otherCape = getSortedCapeExcluding(selectedCape.value.id)
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value] visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
} else { } else {
visibleCapeList.value = getSortedCapes(2) visibleCapeList.value = getSortedCapes(2)
} }
} }
} }
function getSortedCapes(count: number): Cape[] { function getSortedCapes(count: number): Cape[] {
if (!sortedCapes.value || sortedCapes.value.length === 0) return [] if (!sortedCapes.value || sortedCapes.value.length === 0) return []
return sortedCapes.value.slice(0, count) return sortedCapes.value.slice(0, count)
} }
function getSortedCapeExcluding(excludeId: string): Cape | undefined { function getSortedCapeExcluding(excludeId: string): Cape | undefined {
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
return sortedCapes.value.find((cape) => cape.id !== excludeId) return sortedCapes.value.find((cape) => cape.id !== excludeId)
} }
async function loadPreviewSkin() { async function loadPreviewSkin() {
if (uploadedTextureUrl.value) { if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value previewSkin.value = uploadedTextureUrl.value
} else if (currentSkin.value) { } else if (currentSkin.value) {
try { try {
previewSkin.value = await get_normalized_skin_texture(currentSkin.value) previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
} catch (error) { } catch (error) {
console.error('Failed to load skin texture:', error) console.error('Failed to load skin texture:', error)
previewSkin.value = '/src/assets/skins/steve.png' previewSkin.value = '/src/assets/skins/steve.png'
} }
} else { } else {
previewSkin.value = '/src/assets/skins/steve.png' previewSkin.value = '/src/assets/skins/steve.png'
} }
} }
const hasEdits = computed(() => { const hasEdits = computed(() => {
if (mode.value !== 'edit') return true if (mode.value !== 'edit') return true
if (uploadedTextureUrl.value) return true if (uploadedTextureUrl.value) return true
if (!currentSkin.value) return false if (!currentSkin.value) return false
if (variant.value !== currentSkin.value.variant) return true if (variant.value !== currentSkin.value.variant) return true
if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true
return false return false
}) })
const disableSave = computed( const disableSave = computed(
() => () =>
(mode.value === 'new' && !uploadedTextureUrl.value) || (mode.value === 'new' && !uploadedTextureUrl.value) ||
(mode.value === 'edit' && !hasEdits.value), (mode.value === 'edit' && !hasEdits.value),
) )
const saveTooltip = computed(() => { const saveTooltip = computed(() => {
if (isSaving.value) return 'Saving...' if (isSaving.value) return 'Saving...'
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!' if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!' if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
return undefined return undefined
}) })
function resetState() { function resetState() {
mode.value = 'new' mode.value = 'new'
currentSkin.value = null currentSkin.value = null
uploadedTextureUrl.value = null uploadedTextureUrl.value = null
previewSkin.value = '' previewSkin.value = ''
variant.value = 'CLASSIC' variant.value = 'CLASSIC'
selectedCape.value = undefined selectedCape.value = undefined
visibleCapeList.value = [] visibleCapeList.value = []
shouldRestoreModal.value = false shouldRestoreModal.value = false
isSaving.value = false isSaving.value = false
} }
async function show(e: MouseEvent, skin?: Skin) { async function show(e: MouseEvent, skin?: Skin) {
mode.value = skin ? 'edit' : 'new' mode.value = skin ? 'edit' : 'new'
currentSkin.value = skin ?? null currentSkin.value = skin ?? null
if (skin) { if (skin) {
variant.value = skin.variant variant.value = skin.variant
selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id) selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id)
} else { } else {
variant.value = 'CLASSIC' variant.value = 'CLASSIC'
selectedCape.value = undefined selectedCape.value = undefined
} }
visibleCapeList.value = [] visibleCapeList.value = []
initVisibleCapeList() initVisibleCapeList()
await loadPreviewSkin() await loadPreviewSkin()
modal.value?.show(e) modal.value?.show(e)
} }
async function showNew(e: MouseEvent, skinTextureUrl: string) { async function showNew(e: MouseEvent, skinTextureUrl: string) {
mode.value = 'new' mode.value = 'new'
currentSkin.value = null currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl uploadedTextureUrl.value = skinTextureUrl
variant.value = 'CLASSIC' variant.value = await determineModelType(skinTextureUrl)
selectedCape.value = undefined selectedCape.value = undefined
visibleCapeList.value = [] visibleCapeList.value = []
initVisibleCapeList() initVisibleCapeList()
await loadPreviewSkin() await loadPreviewSkin()
modal.value?.show(e) modal.value?.show(e)
} }
async function restoreWithNewTexture(skinTextureUrl: string) { async function restoreWithNewTexture(skinTextureUrl: string) {
uploadedTextureUrl.value = skinTextureUrl uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin() await loadPreviewSkin()
if (shouldRestoreModal.value) { if (shouldRestoreModal.value) {
setTimeout(() => { setTimeout(() => {
modal.value?.show() modal.value?.show()
shouldRestoreModal.value = false shouldRestoreModal.value = false
}, 0) }, 0)
} }
} }
function hide() { function hide() {
modal.value?.hide() modal.value?.hide()
setTimeout(() => resetState(), 250) setTimeout(() => resetState(), 250)
} }
function selectCape(cape: Cape | undefined) { function selectCape(cape: Cape | undefined) {
if (cape && selectedCape.value?.id !== cape.id) { if (cape && selectedCape.value?.id !== cape.id) {
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id) const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
if (!isInVisibleList && visibleCapeList.value.length > 0) { if (!isInVisibleList && visibleCapeList.value.length > 0) {
visibleCapeList.value.splice(0, 1, cape) visibleCapeList.value.splice(0, 1, cape)
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) { if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
const otherCape = getSortedCapeExcluding(cape.id) const otherCape = getSortedCapeExcluding(cape.id)
if (otherCape) { if (otherCape) {
visibleCapeList.value.splice(1, 1, otherCape) visibleCapeList.value.splice(1, 1, otherCape)
} }
} }
} }
} }
selectedCape.value = cape selectedCape.value = cape
} }
function handleCapeSelected(cape: Cape | undefined) { function handleCapeSelected(cape: Cape | undefined) {
selectCape(cape) selectCape(cape)
if (shouldRestoreModal.value) { if (shouldRestoreModal.value) {
setTimeout(() => { setTimeout(() => {
modal.value?.show() modal.value?.show()
shouldRestoreModal.value = false shouldRestoreModal.value = false
}, 0) }, 0)
} }
} }
function handleCapeCancel() { function handleCapeCancel() {
if (shouldRestoreModal.value) { if (shouldRestoreModal.value) {
setTimeout(() => { setTimeout(() => {
modal.value?.show() modal.value?.show()
shouldRestoreModal.value = false shouldRestoreModal.value = false
}, 0) }, 0)
} }
} }
function openSelectCapeModal(e: MouseEvent) { function openSelectCapeModal(e: MouseEvent) {
if (!selectCapeModal.value) return if (!selectCapeModal.value) return
shouldRestoreModal.value = true shouldRestoreModal.value = true
modal.value?.hide() modal.value?.hide()
setTimeout(() => { setTimeout(() => {
selectCapeModal.value?.show( selectCapeModal.value?.show(
e, e,
currentSkin.value?.texture_key, currentSkin.value?.texture_key,
selectedCape.value, selectedCape.value,
previewSkin.value, previewSkin.value,
variant.value, variant.value,
) )
}, 0) }, 0)
} }
function openUploadSkinModal(e: MouseEvent) { function openUploadSkinModal(e: MouseEvent) {
shouldRestoreModal.value = true shouldRestoreModal.value = true
modal.value?.hide() modal.value?.hide()
emit('open-upload-modal', e) emit('open-upload-modal', e)
} }
function restoreModal() { function restoreModal() {
if (shouldRestoreModal.value) { if (shouldRestoreModal.value) {
setTimeout(() => { setTimeout(() => {
const fakeEvent = new MouseEvent('click') const fakeEvent = new MouseEvent('click')
modal.value?.show(fakeEvent) modal.value?.show(fakeEvent)
shouldRestoreModal.value = false shouldRestoreModal.value = false
}, 500) }, 500)
} }
} }
async function save() { async function save() {
isSaving.value = true isSaving.value = true
try { try {
let textureUrl: string let textureUrl: string
if (uploadedTextureUrl.value) { if (uploadedTextureUrl.value) {
textureUrl = uploadedTextureUrl.value textureUrl = uploadedTextureUrl.value
} else { } else {
textureUrl = currentSkin.value!.texture textureUrl = currentSkin.value!.texture
} }
await unequip_skin() await unequip_skin()
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer()) const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') { if (mode.value === 'new') {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value) await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved') emit('saved')
} else { } else {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value) await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
await remove_custom_skin(currentSkin.value!) await remove_custom_skin(currentSkin.value!)
emit('saved') emit('saved')
} }
hide() hide()
} catch (err) { } catch (err) {
handleError(err) handleError(err)
} finally { } finally {
isSaving.value = false isSaving.value = false
} }
} }
watch([uploadedTextureUrl, currentSkin], async () => { watch([uploadedTextureUrl, currentSkin], async () => {
await loadPreviewSkin() await loadPreviewSkin()
}) })
watch( watch(
() => props.capes, () => props.capes,
() => { () => {
initVisibleCapeList() initVisibleCapeList()
}, },
{ immediate: true }, { immediate: true },
) )
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'saved'): void (event: 'saved'): void
(event: 'deleted', skin: Skin): void (event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void (event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>() }>()
defineExpose({ defineExpose({
show, show,
showNew, showNew,
restoreWithNewTexture, restoreWithNewTexture,
hide, hide,
shouldRestoreModal, shouldRestoreModal,
restoreModal, restoreModal,
}) })
</script> </script>

View File

@@ -1,33 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateRef, ref, computed } from 'vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
import {
ButtonStyled,
ScrollablePanel,
CapeButton,
CapeLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { CheckIcon, XIcon } from '@modrinth/assets' import { CheckIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CapeButton,
CapeLikeTextButton,
ScrollablePanel,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
const modal = useTemplateRef('modal') const modal = useTemplateRef('modal')
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select', cape: Cape | undefined): void (e: 'select', cape: Cape | undefined): void
(e: 'cancel'): void (e: 'cancel'): void
}>() }>()
const props = defineProps<{ const props = defineProps<{
capes: Cape[] capes: Cape[]
}>() }>()
const sortedCapes = computed(() => { const sortedCapes = computed(() => {
return [...props.capes].sort((a, b) => { return [...props.capes].sort((a, b) => {
const nameA = (a.name || '').toLowerCase() const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase() const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB) return nameA.localeCompare(nameB)
}) })
}) })
const currentSkinId = ref<string | undefined>() const currentSkinId = ref<string | undefined>()
@@ -37,104 +38,104 @@ const currentCapeTexture = computed<string | undefined>(() => currentCape.value?
const currentCape = ref<Cape | undefined>() const currentCape = ref<Cape | undefined>()
function show( function show(
e: MouseEvent, e: MouseEvent,
skinId?: string, skinId?: string,
selected?: Cape, selected?: Cape,
skinTexture?: string, skinTexture?: string,
variant?: SkinModel, variant?: SkinModel,
) { ) {
currentSkinId.value = skinId currentSkinId.value = skinId
currentSkinTexture.value = skinTexture currentSkinTexture.value = skinTexture
currentSkinVariant.value = variant || 'CLASSIC' currentSkinVariant.value = variant || 'CLASSIC'
currentCape.value = selected currentCape.value = selected
modal.value?.show(e) modal.value?.show(e)
} }
function select() { function select() {
emit('select', currentCape.value) emit('select', currentCape.value)
hide() hide()
} }
function hide() { function hide() {
modal.value?.hide() modal.value?.hide()
emit('cancel') emit('cancel')
} }
function updateSelectedCape(cape: Cape | undefined) { function updateSelectedCape(cape: Cape | undefined) {
currentCape.value = cape currentCape.value = cape
} }
function onModalHide() { function onModalHide() {
emit('cancel') emit('cancel')
} }
defineExpose({ defineExpose({
show, show,
hide, hide,
}) })
</script> </script>
<template> <template>
<ModalWrapper ref="modal" @on-hide="onModalHide"> <ModalWrapper ref="modal" @on-hide="onModalHide">
<template #title> <template #title>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-lg font-extrabold text-heading">Change cape</span> <span class="text-lg font-extrabold text-heading">Change cape</span>
</div> </div>
</template> </template>
<div class="flex flex-col md:flex-row gap-6"> <div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative"> <div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0"> <div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer <SkinPreviewRenderer
v-if="currentSkinTexture" v-if="currentSkinTexture"
:cape-src="currentCapeTexture" :cape-src="currentCapeTexture"
:texture-src="currentSkinTexture" :texture-src="currentSkinTexture"
:variant="currentSkinVariant" :variant="currentSkinVariant"
:scale="1.4" :scale="1.4"
:fov="50" :fov="50"
:initial-rotation="Math.PI + Math.PI / 8" :initial-rotation="Math.PI + Math.PI / 8"
class="h-full w-full" class="h-full w-full"
/> />
</div> </div>
</div> </div>
<div class="flex flex-col gap-4 w-full my-auto"> <div class="flex flex-col gap-4 w-full my-auto">
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full"> <ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full"> <div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
<CapeLikeTextButton <CapeLikeTextButton
tooltip="No Cape" tooltip="No Cape"
:highlighted="!currentCape" :highlighted="!currentCape"
@click="updateSelectedCape(undefined)" @click="updateSelectedCape(undefined)"
> >
<template #icon> <template #icon>
<XIcon /> <XIcon />
</template> </template>
<span>None</span> <span>None</span>
</CapeLikeTextButton> </CapeLikeTextButton>
<CapeButton <CapeButton
v-for="cape in sortedCapes" v-for="cape in sortedCapes"
:id="cape.id" :id="cape.id"
:key="cape.id" :key="cape.id"
:name="cape.name" :name="cape.name"
:texture="cape.texture" :texture="cape.texture"
:selected="currentCape?.id === cape.id" :selected="currentCape?.id === cape.id"
@select="updateSelectedCape(cape)" @select="updateSelectedCape(cape)"
/> />
</div> </div>
</ScrollablePanel> </ScrollablePanel>
</div> </div>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button @click="select"> <button @click="select">
<CheckIcon /> <CheckIcon />
Select Select
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button @click="hide"> <button @click="hide">
<XIcon /> <XIcon />
Cancel Cancel
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -1,40 +1,41 @@
<template> <template>
<ModalWrapper ref="modal" @on-hide="hide(true)"> <ModalWrapper ref="modal" @on-hide="hide(true)">
<template #title> <template #title>
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span> <span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
</template> </template>
<div class="relative"> <div class="relative">
<div <div
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative" class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
@click="triggerFileInput" @click="triggerFileInput"
> >
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2"> <p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
<UploadIcon /> Select skin texture file <UploadIcon /> Select skin texture file
</p> </p>
<p class="mx-auto mt-0 text-secondary text-sm text-center"> <p class="mx-auto mt-0 text-secondary text-sm text-center">
Drag and drop or click here to browse Drag and drop or click here to browse
</p> </p>
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
accept="image/png" accept="image/png"
class="hidden" class="hidden"
@change="handleInputFileChange" @change="handleInputFileChange"
/> />
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onBeforeUnmount, watch } from 'vue'
import { UploadIcon } from '@modrinth/assets' import { UploadIcon } from '@modrinth/assets'
import { useNotifications } from '@/store/state' import { injectNotificationManager } from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview' import { getCurrentWebview } from '@tauri-apps/api/webview'
import { onBeforeUnmount, ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins' import { get_dragged_skin_data } from '@/helpers/skins'
const notifications = useNotifications() const { addNotification } = injectNotificationManager()
const modal = ref() const modal = ref()
const fileInput = ref<HTMLInputElement>() const fileInput = ref<HTMLInputElement>()
@@ -42,98 +43,98 @@ const unlisten = ref<() => void>()
const modalVisible = ref(false) const modalVisible = ref(false)
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'uploaded', data: ArrayBuffer): void (e: 'uploaded', data: ArrayBuffer): void
(e: 'canceled'): void (e: 'canceled'): void
}>() }>()
function show(e?: MouseEvent) { function show(e?: MouseEvent) {
modal.value?.show(e) modal.value?.show(e)
modalVisible.value = true modalVisible.value = true
setupDragDropListener() setupDragDropListener()
} }
function hide(emitCanceled = false) { function hide(emitCanceled = false) {
modal.value?.hide() modal.value?.hide()
modalVisible.value = false modalVisible.value = false
cleanupDragDropListener() cleanupDragDropListener()
resetState() resetState()
if (emitCanceled) { if (emitCanceled) {
emit('canceled') emit('canceled')
} }
} }
function resetState() { function resetState() {
if (fileInput.value) fileInput.value.value = '' if (fileInput.value) fileInput.value.value = ''
} }
function triggerFileInput() { function triggerFileInput() {
fileInput.value?.click() fileInput.value?.click()
} }
async function handleInputFileChange(e: Event) { async function handleInputFileChange(e: Event) {
const files = (e.target as HTMLInputElement).files const files = (e.target as HTMLInputElement).files
if (!files || files.length === 0) { if (!files || files.length === 0) {
return return
} }
const file = files[0] const file = files[0]
const buffer = await file.arrayBuffer() const buffer = await file.arrayBuffer()
await processData(buffer) await processData(buffer)
} }
async function setupDragDropListener() { async function setupDragDropListener() {
try { try {
if (modalVisible.value) { if (modalVisible.value) {
await cleanupDragDropListener() await cleanupDragDropListener()
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => { unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') { if (event.payload.type !== 'drop') {
return return
} }
if (!event.payload.paths || event.payload.paths.length === 0) { if (!event.payload.paths || event.payload.paths.length === 0) {
return return
} }
const filePath = event.payload.paths[0] const filePath = event.payload.paths[0]
try { try {
const data = await get_dragged_skin_data(filePath) const data = await get_dragged_skin_data(filePath)
await processData(data.buffer) await processData(data.buffer)
} catch (error) { } catch (error) {
notifications.addNotification({ addNotification({
title: 'Error processing file', title: 'Error processing file',
text: error instanceof Error ? error.message : 'Failed to read the dropped file.', text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
type: 'error', type: 'error',
}) })
} }
}) })
} }
} catch (error) { } catch (error) {
console.error('Failed to set up drag and drop listener:', error) console.error('Failed to set up drag and drop listener:', error)
} }
} }
async function cleanupDragDropListener() { async function cleanupDragDropListener() {
if (unlisten.value) { if (unlisten.value) {
unlisten.value() unlisten.value()
unlisten.value = undefined unlisten.value = undefined
} }
} }
async function processData(buffer: ArrayBuffer) { async function processData(buffer: ArrayBuffer) {
emit('uploaded', buffer) emit('uploaded', buffer)
hide() hide()
} }
watch(modalVisible, (isVisible) => { watch(modalVisible, (isVisible) => {
if (isVisible) { if (isVisible) {
setupDragDropListener() setupDragDropListener()
} else { } else {
cleanupDragDropListener() cleanupDragDropListener()
} }
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
cleanupDragDropListener() cleanupDragDropListener()
}) })
defineExpose({ show, hide }) defineExpose({ show, hide })

View File

@@ -1,49 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { import {
EyeIcon, EyeIcon,
FolderOpenIcon, FolderOpenIcon,
MoreVerticalIcon, MoreVerticalIcon,
PlayIcon, PlayIcon,
SpinnerIcon, SpinnerIcon,
StopCircleIcon, StopCircleIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, ButtonStyled,
commonMessages, commonMessages,
OverflowMenu, injectNotificationManager,
SmartClickable, OverflowMenu,
useRelativeTime, SmartClickable,
useRelativeTime,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { showProfileInFolder } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import { get_project } from '@/helpers/cache'
import { capitalizeString } from '@modrinth/utils' import { capitalizeString } from '@modrinth/utils'
import { kill, run } from '@/helpers/profile' import { convertFileSrc } from '@tauri-apps/api/core'
import { handleSevereError } from '@/store/error' import { useVIntl } from '@vintl/vintl'
import { trackEvent } from '@/helpers/analytics' import type { Dayjs } from 'dayjs'
import { get_by_profile_path } from '@/helpers/process' import dayjs from 'dayjs'
import { handleError } from '@/store/notifications' import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { process_listener } from '@/helpers/events' import { useRouter } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils'
import { handleSevereError } from '@/store/error'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const router = useRouter() const router = useRouter()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'play' | 'stop'): void (e: 'play' | 'stop'): void
}>() }>()
const props = defineProps<{ const props = defineProps<{
instance: GameInstance instance: GameInstance
last_played: Dayjs last_played: Dayjs
}>() }>()
const loadingModpack = ref(!!props.instance.linked_data) const loadingModpack = ref(!!props.instance.linked_data)
@@ -51,180 +53,180 @@ const loadingModpack = ref(!!props.instance.linked_data)
const modpack = ref() const modpack = ref()
if (props.instance.linked_data) { if (props.instance.linked_data) {
nextTick().then(async () => { nextTick().then(async () => {
modpack.value = await get_project(props.instance.linked_data?.project_id, 'must_revalidate') modpack.value = await get_project(props.instance.linked_data?.project_id, 'must_revalidate')
loadingModpack.value = false loadingModpack.value = false
}) })
} }
const instanceIcon = computed(() => props.instance.icon_path) const instanceIcon = computed(() => props.instance.icon_path)
const loader = computed(() => { const loader = computed(() => {
if (props.instance.loader === 'vanilla') { if (props.instance.loader === 'vanilla') {
return 'Minecraft' return 'Minecraft'
} else if (props.instance.loader === 'neoforge') { } else if (props.instance.loader === 'neoforge') {
return 'NeoForge' return 'NeoForge'
} else { } else {
return capitalizeString(props.instance.loader) return capitalizeString(props.instance.loader)
} }
}) })
const loading = ref(false) const loading = ref(false)
const playing = ref(false) const playing = ref(false)
const play = async (event: MouseEvent) => { const play = async (event: MouseEvent) => {
event?.stopPropagation() event?.stopPropagation()
loading.value = true loading.value = true
await run(props.instance.path) await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path })) .catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => { .finally(() => {
trackEvent('InstancePlay', { trackEvent('InstancePlay', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
source: 'InstanceItem', source: 'InstanceItem',
}) })
}) })
emit('play') emit('play')
loading.value = false loading.value = false
} }
const stop = async (event: MouseEvent) => { const stop = async (event: MouseEvent) => {
event?.stopPropagation() event?.stopPropagation()
loading.value = true loading.value = true
await kill(props.instance.path).catch(handleError) await kill(props.instance.path).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
source: 'InstanceItem', source: 'InstanceItem',
}) })
emit('stop') emit('stop')
loading.value = false loading.value = false
} }
const unlistenProcesses = await process_listener(async () => { const unlistenProcesses = await process_listener(async () => {
await checkProcess() await checkProcess()
}) })
const checkProcess = async () => { const checkProcess = async () => {
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError) const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
playing.value = runningProcesses.length > 0 playing.value = runningProcesses.length > 0
} }
onMounted(() => { onMounted(() => {
checkProcess() checkProcess()
}) })
onUnmounted(() => { onUnmounted(() => {
unlistenProcesses() unlistenProcesses()
}) })
</script> </script>
<template> <template>
<SmartClickable> <SmartClickable>
<template #clickable> <template #clickable>
<router-link <router-link
class="no-click-animation" class="no-click-animation"
:to="`/instance/${encodeURIComponent(instance.path)}`" :to="`/instance/${encodeURIComponent(instance.path)}`"
/> />
</template> </template>
<div <div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised rounded-xl smart-clickable:highlight-on-hover" class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised rounded-xl smart-clickable:highlight-on-hover"
> >
<Avatar <Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined" :src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
:tint-by="instance.path" :tint-by="instance.path"
size="48px" size="48px"
/> />
<div class="flex flex-col col-span-2 justify-between h-full"> <div class="flex flex-col col-span-2 justify-between h-full">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover"> <div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ instance.name }} {{ instance.name }}
</div> </div>
</div> </div>
<div class="flex items-center gap-2 text-sm text-secondary"> <div class="flex items-center gap-2 text-sm text-secondary">
<div <div
v-tooltip=" v-tooltip="
instance.last_played instance.last_played
? dayjs(instance.last_played).format('MMMM D, YYYY [at] h:mm A') ? dayjs(instance.last_played).format('MMMM D, YYYY [at] h:mm A')
: null : null
" "
class="w-fit shrink-0" class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }" :class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
> >
<template v-if="last_played"> <template v-if="last_played">
{{ {{
formatMessage(commonMessages.playedLabel, { formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(last_played.toISOString?.()), time: formatRelativeTime(last_played.toISOString?.()),
}) })
}} }}
</template> </template>
<template v-else> Not played yet </template> <template v-else> Not played yet </template>
</div> </div>
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary"> <span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
<router-link <router-link
class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events" class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
:to="`/project/${modpack.id}`" :to="`/project/${modpack.id}`"
> >
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" /> <Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />
<span class="truncate">{{ modpack.title }}</span> <span class="truncate">{{ modpack.title }}</span>
</router-link> </router-link>
({{ loader }} {{ instance.game_version }}) ({{ loader }} {{ instance.game_version }})
</span> </span>
<span v-else-if="loadingModpack" class="flex items-center gap-1 truncate text-secondary"> <span v-else-if="loadingModpack" class="flex items-center gap-1 truncate text-secondary">
<SpinnerIcon class="animate-spin shrink-0" /> <SpinnerIcon class="animate-spin shrink-0" />
<span class="truncate">Loading modpack...</span> <span class="truncate">Loading modpack...</span>
</span> </span>
<span v-else class="flex items-center gap-1 truncate text-secondary"> <span v-else class="flex items-center gap-1 truncate text-secondary">
{{ loader }} {{ loader }}
{{ instance.game_version }} {{ instance.game_version }}
</span> </span>
</div> </div>
</div> </div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events"> <div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled v-if="playing && !loading" color="red"> <ButtonStyled v-if="playing && !loading" color="red">
<button @click="stop"> <button @click="stop">
<StopCircleIcon aria-hidden="true" /> <StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }} {{ formatMessage(commonMessages.stopButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else> <ButtonStyled v-else>
<button <button
v-tooltip="playing ? 'Instance is already open' : null" v-tooltip="playing ? 'Instance is already open' : null"
:disabled="playing || loading" :disabled="playing || loading"
@click="play" @click="play"
> >
<SpinnerIcon v-if="loading" class="animate-spin" /> <SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" /> <PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }} {{ formatMessage(commonMessages.playButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled circular type="transparent"> <ButtonStyled circular type="transparent">
<OverflowMenu <OverflowMenu
:options="[ :options="[
{ {
id: 'open-instance', id: 'open-instance',
shown: !!instance.path, shown: !!instance.path,
action: () => router.push(encodeURI(`/instance/${instance.path}`)), action: () => router.push(encodeURI(`/instance/${instance.path}`)),
}, },
{ {
id: 'open-folder', id: 'open-folder',
action: () => showProfileInFolder(instance.path), action: () => showProfileInFolder(instance.path),
}, },
]" ]"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #open-instance> <template #open-instance>
<EyeIcon aria-hidden="true" /> <EyeIcon aria-hidden="true" />
View instance View instance
</template> </template>
<template #open-folder> <template #open-folder>
<FolderOpenIcon aria-hidden="true" /> <FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }} {{ formatMessage(commonMessages.openFolderButton) }}
</template> </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
</SmartClickable> </SmartClickable>
</template> </template>

View File

@@ -1,56 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
type ServerWorld,
type ServerData,
type WorldWithProfile,
get_recent_worlds,
getWorldIdentifier,
get_profile_protocol_version,
refreshServerData,
start_join_server,
start_join_singleplayer_world,
} from '@/helpers/worlds.ts'
import { HeadingLink, GAME_MODES } from '@modrinth/ui'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import { watch, onMounted, onUnmounted, ref, computed } from 'vue'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.ts' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { kill, run } from '@/helpers/profile'
import { handleError } from '@/store/notifications' import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events' import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process' import { get_all } from '@/helpers/process'
import { kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import {
get_profile_protocol_version,
get_recent_worlds,
getWorldIdentifier,
type ProtocolVersion,
refreshServerData,
type ServerData,
type ServerWorld,
start_join_server,
start_join_singleplayer_world,
type WorldWithProfile,
} from '@/helpers/worlds.ts'
import { handleSevereError } from '@/store/error' import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/theme.ts'
const { handleError } = injectNotificationManager()
const props = defineProps<{ const props = defineProps<{
recentInstances: GameInstance[] recentInstances: GameInstance[]
}>() }>()
const theme = useTheming() const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([]) const jumpBackInItems = ref<JumpBackInItem[]>([])
const serverData = ref<Record<string, ServerData>>({}) const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, number | null>>({}) const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const MIN_JUMP_BACK_IN = 3 const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6 const MAX_JUMP_BACK_IN = 6
const TWO_WEEKS_AGO = dayjs().subtract(14, 'day') const TWO_WEEKS_AGO = dayjs().subtract(14, 'day')
type BaseJumpBackInItem = { type BaseJumpBackInItem = {
last_played: Dayjs last_played: Dayjs
instance: GameInstance instance: GameInstance
} }
type InstanceJumpBackInItem = BaseJumpBackInItem & { type InstanceJumpBackInItem = BaseJumpBackInItem & {
type: 'instance' type: 'instance'
} }
type WorldJumpBackInItem = BaseJumpBackInItem & { type WorldJumpBackInItem = BaseJumpBackInItem & {
type: 'world' type: 'world'
world: WorldWithProfile world: WorldWithProfile
} }
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
@@ -58,247 +61,244 @@ type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home')) const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home'))
watch([() => props.recentInstances, () => showWorlds.value], async () => { watch([() => props.recentInstances, () => showWorlds.value], async () => {
await populateJumpBackIn().catch(() => { await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in') console.error('Failed to populate jump back in')
}) })
}) })
await populateJumpBackIn().catch(() => { await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in') console.error('Failed to populate jump back in')
}) })
async function populateJumpBackIn() { async function populateJumpBackIn() {
console.info('Repopulating jump back in...') console.info('Repopulating jump back in...')
const worldItems: WorldJumpBackInItem[] = [] const worldItems: WorldJumpBackInItem[] = []
if (showWorlds.value) { if (showWorlds.value) {
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite']) const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite'])
worlds.forEach((world) => { worlds.forEach((world) => {
const instance = props.recentInstances.find((instance) => instance.path === world.profile) const instance = props.recentInstances.find((instance) => instance.path === world.profile)
if (!instance || !world.last_played) { if (!instance || !world.last_played) {
return return
} }
worldItems.push({ worldItems.push({
type: 'world', type: 'world',
last_played: dayjs(world.last_played ?? 0), last_played: dayjs(world.last_played ?? 0),
world: world, world: world,
instance: instance, instance: instance,
}) })
}) })
const servers: { const servers: {
instancePath: string instancePath: string
address: string address: string
}[] = worldItems }[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance) .filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({ .map((item) => ({
instancePath: item.instance.path, instancePath: item.instance.path,
address: (item.world as ServerWorld).address, address: (item.world as ServerWorld).address,
})) }))
// fetch protocol versions for all unique MC versions with server worlds // fetch protocol versions for all unique MC versions with server worlds
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath)) const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
await Promise.all( await Promise.all(
[...uniqueServerInstances].map((path) => [...uniqueServerInstances].map((path) =>
get_profile_protocol_version(path) get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer)) .then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => { .catch(() => {
console.error(`Failed to get profile protocol for: ${path} `) console.error(`Failed to get profile protocol for: ${path} `)
}), }),
), ),
) )
// initialize server data // initialize server data
servers.forEach(({ address }) => { servers.forEach(({ address }) => {
if (!serverData.value[address]) { if (!serverData.value[address]) {
serverData.value[address] = { serverData.value[address] = {
refreshing: true, refreshing: true,
} }
} }
}) })
// fetch each server's data servers.forEach(({ instancePath, address }) =>
Promise.all( refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
servers.map(({ instancePath, address }) => )
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address), }
),
)
}
const instanceItems: InstanceJumpBackInItem[] = [] const instanceItems: InstanceJumpBackInItem[] = []
for (const instance of props.recentInstances) { for (const instance of props.recentInstances) {
const worldItem = worldItems.find((item) => item.instance.path === instance.path) const worldItem = worldItems.find((item) => item.instance.path === instance.path)
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) { if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
continue continue
} }
instanceItems.push({ instanceItems.push({
type: 'instance', type: 'instance',
last_played: dayjs(instance.last_played ?? 0), last_played: dayjs(instance.last_played ?? 0),
instance: instance, instance: instance,
}) })
} }
const items: JumpBackInItem[] = [...worldItems, ...instanceItems] const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))) items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
jumpBackInItems.value = items jumpBackInItems.value = items
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO)) .filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
.slice(0, MAX_JUMP_BACK_IN) .slice(0, MAX_JUMP_BACK_IN)
} }
async function refreshServer(address: string, instancePath: string) { function refreshServer(address: string, instancePath: string) {
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address) refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
} }
async function joinWorld(world: WorldWithProfile) { async function joinWorld(world: WorldWithProfile) {
console.log(`Joining world ${getWorldIdentifier(world)}`) console.log(`Joining world ${getWorldIdentifier(world)}`)
if (world.type === 'server') { if (world.type === 'server') {
await start_join_server(world.profile, world.address).catch(handleError) await start_join_server(world.profile, world.address).catch(handleError)
} else if (world.type === 'singleplayer') { } else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(world.profile, world.path).catch(handleError) await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
} }
} }
async function playInstance(instance: GameInstance) { async function playInstance(instance: GameInstance) {
await run(instance.path) await run(instance.path)
.catch((err) => handleSevereError(err, { profilePath: instance.path })) .catch((err) => handleSevereError(err, { profilePath: instance.path }))
.finally(() => { .finally(() => {
trackEvent('InstancePlay', { trackEvent('InstancePlay', {
loader: instance.loader, loader: instance.loader,
game_version: instance.game_version, game_version: instance.game_version,
source: 'WorldItem', source: 'WorldItem',
}) })
}) })
} }
async function stopInstance(path: string) { async function stopInstance(path: string) {
await kill(path).catch(handleError) await kill(path).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
source: 'RecentWorldsList', source: 'RecentWorldsList',
}) })
} }
const currentProfile = ref<string>() const currentProfile = ref<string>()
const currentWorld = ref<string>() const currentWorld = ref<string>()
const unlistenProcesses = await process_listener(async () => { const unlistenProcesses = await process_listener(async () => {
await checkProcesses() await checkProcesses()
}) })
const unlistenProfiles = await profile_listener(async () => { const unlistenProfiles = await profile_listener(async () => {
await populateJumpBackIn().catch(() => { await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in') console.error('Failed to populate jump back in')
}) })
}) })
const runningInstances = ref<string[]>([]) const runningInstances = ref<string[]>([])
type ProcessMetadata = { type ProcessMetadata = {
uuid: string uuid: string
profile_path: string profile_path: string
start_time: string start_time: string
} }
const checkProcesses = async () => { const checkProcesses = async () => {
const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError) const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError)
const runningPaths = runningProcesses.map((x) => x.profile_path) const runningPaths = runningProcesses.map((x) => x.profile_path)
const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x)) const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x))
if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) { if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) {
currentProfile.value = undefined currentProfile.value = undefined
currentWorld.value = undefined currentWorld.value = undefined
} }
runningInstances.value = runningPaths runningInstances.value = runningPaths
} }
onMounted(() => { onMounted(() => {
checkProcesses() checkProcesses()
}) })
onUnmounted(() => { onUnmounted(() => {
unlistenProcesses() unlistenProcesses()
unlistenProfiles() unlistenProfiles()
}) })
</script> </script>
<template> <template>
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2"> <div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1"> <HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in Jump back in
</HeadingLink> </HeadingLink>
<span <span
v-else v-else
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold" class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
> >
Jump back in Jump back in
</span> </span>
<div class="grid-when-huge flex flex-col w-full gap-2"> <div class="grid-when-huge flex flex-col w-full gap-2">
<template <template
v-for="item in jumpBackInItems" v-for="item in jumpBackInItems"
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`" :key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
> >
<WorldItem <WorldItem
v-if="item.type === 'world'" v-if="item.type === 'world'"
:world="item.world" :world="item.world"
:playing-instance="runningInstances.includes(item.instance.path)" :playing-instance="runningInstances.includes(item.instance.path)"
:playing-world=" :playing-world="
currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world) currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world)
" "
:refreshing=" :refreshing="
item.world.type === 'server' item.world.type === 'server'
? serverData[item.world.address].refreshing && !serverData[item.world.address].status ? serverData[item.world.address].refreshing && !serverData[item.world.address].status
: undefined : undefined
" "
supports-quick-play supports-quick-play
:server-status=" :server-status="
item.world.type === 'server' ? serverData[item.world.address].status : undefined item.world.type === 'server' ? serverData[item.world.address].status : undefined
" "
:rendered-motd=" :rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
" "
:current-protocol="protocolVersions[item.instance.path]" :current-protocol="protocolVersions[item.instance.path]"
:game-mode=" :game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
" "
:instance-path="item.instance.path" :instance-path="item.instance.path"
:instance-name="item.instance.name" :instance-name="item.instance.name"
:instance-icon="item.instance.icon_path" :instance-icon="item.instance.icon_path"
@refresh=" @refresh="
() => () =>
item.world.type === 'server' item.world.type === 'server'
? refreshServer(item.world.address, item.instance.path) ? refreshServer(item.world.address, item.instance.path)
: {} : {}
" "
@update="() => populateJumpBackIn()" @update="() => populateJumpBackIn()"
@play=" @play="
() => { () => {
currentProfile = item.instance.path currentProfile = item.instance.path
currentWorld = getWorldIdentifier(item.world) currentWorld = getWorldIdentifier(item.world)
joinWorld(item.world) joinWorld(item.world)
} }
" "
@play-instance=" @play-instance="
() => { () => {
currentProfile = item.instance.path currentProfile = item.instance.path
playInstance(item.instance) playInstance(item.instance)
} }
" "
@stop="() => stopInstance(item.instance.path)" @stop="() => stopInstance(item.instance.path)"
/> />
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" /> <InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.grid-when-huge { .grid-when-huge {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
} }
</style> </style>

View File

@@ -1,42 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import { import {
useRelativeTime, ClipboardCopyIcon,
Avatar, EditIcon,
ButtonStyled, EyeIcon,
commonMessages, FolderOpenIcon,
OverflowMenu, IssuesIcon,
SmartClickable, MoreVerticalIcon,
} from '@modrinth/ui' NoSignalIcon,
import { PlayIcon,
IssuesIcon, SignalIcon,
EyeIcon, SkullIcon,
ClipboardCopyIcon, SpinnerIcon,
EditIcon, StopCircleIcon,
FolderOpenIcon, TrashIcon,
MoreVerticalIcon, UpdatedIcon,
NoSignalIcon, UserIcon,
PlayIcon, XIcon,
SignalIcon,
SkullIcon,
SpinnerIcon,
StopCircleIcon,
TrashIcon,
UpdatedIcon,
UserIcon,
XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
useRelativeTime,
} from '@modrinth/ui'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { MessageDescriptor } from '@vintl/vintl' import type { MessageDescriptor } from '@vintl/vintl'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { computed } from 'vue' import { computed } from 'vue'
import { copyToClipboard } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Tooltip } from 'floating-vue'
import { copyToClipboard } from '@/helpers/utils'
import type {
ProtocolVersion,
ServerStatus,
ServerWorld,
SingleplayerWorld,
World,
} from '@/helpers/worlds.ts'
import { getWorldIdentifier, set_world_display_status } from '@/helpers/worlds.ts'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
@@ -44,463 +51,473 @@ const formatRelativeTime = useRelativeTime()
const router = useRouter() const router = useRouter()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void (e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'open-folder', world: SingleplayerWorld): void (e: 'open-folder', world: SingleplayerWorld): void
}>() }>()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
world: World world: World
playingInstance?: boolean playingInstance?: boolean
playingWorld?: boolean playingWorld?: boolean
startingInstance?: boolean startingInstance?: boolean
supportsQuickPlay?: boolean supportsServerQuickPlay?: boolean
currentProtocol?: number | null supportsWorldQuickPlay?: boolean
highlighted?: boolean currentProtocol?: ProtocolVersion | null
highlighted?: boolean
// Server only // Server only
refreshing?: boolean refreshing?: boolean
serverStatus?: ServerStatus serverStatus?: ServerStatus
renderedMotd?: string renderedMotd?: string
// Singleplayer only // Singleplayer only
gameMode?: { gameMode?: {
icon: Component icon: Component
message: MessageDescriptor message: MessageDescriptor
} }
// Instance // Instance
instancePath?: string instancePath?: string
instanceName?: string instanceName?: string
instanceIcon?: string instanceIcon?: string
}>(), }>(),
{ {
playingInstance: false, playingInstance: false,
playingWorld: false, playingWorld: false,
startingInstance: false, startingInstance: false,
supportsQuickPlay: false, supportsServerQuickPlay: true,
currentProtocol: null, supportsWorldQuickPlay: false,
currentProtocol: null,
refreshing: false, refreshing: false,
serverStatus: undefined, serverStatus: undefined,
renderedMotd: undefined, renderedMotd: undefined,
gameMode: undefined, gameMode: undefined,
instancePath: undefined, instancePath: undefined,
instanceName: undefined, instanceName: undefined,
instanceIcon: undefined, instanceIcon: undefined,
}, },
) )
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld) const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
const hasPlayersTooltip = computed( const hasPlayersTooltip = computed(
() => !!props.serverStatus?.players?.sample && props.serverStatus.players?.sample?.length > 0, () => !!props.serverStatus?.players?.sample && props.serverStatus.players?.sample?.length > 0,
) )
const serverIncompatible = computed( const serverIncompatible = computed(
() => () =>
!!props.serverStatus && !!props.serverStatus &&
!!props.serverStatus.version?.protocol && !!props.serverStatus.version?.protocol &&
!!props.currentProtocol && !!props.currentProtocol &&
props.serverStatus.version.protocol !== props.currentProtocol, (props.serverStatus.version.protocol !== props.currentProtocol.version ||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
) )
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked) const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const messages = defineMessages({ const messages = defineMessages({
hardcore: { hardcore: {
id: 'instance.worlds.hardcore', id: 'instance.worlds.hardcore',
defaultMessage: 'Hardcore mode', defaultMessage: 'Hardcore mode',
}, },
cantConnect: { cantConnect: {
id: 'instance.worlds.cant_connect', id: 'instance.worlds.cant_connect',
defaultMessage: "Can't connect to server", defaultMessage: "Can't connect to server",
}, },
aMinecraftServer: { aMinecraftServer: {
id: 'instance.worlds.a_minecraft_server', id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server', defaultMessage: 'A Minecraft Server',
}, },
noQuickPlay: { noServerQuickPlay: {
id: 'instance.worlds.no_quick_play', id: 'instance.worlds.no_server_quick_play',
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+', defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
}, },
gameAlreadyOpen: { noSingleplayerQuickPlay: {
id: 'instance.worlds.game_already_open', id: 'instance.worlds.no_singleplayer_quick_play',
defaultMessage: 'Instance is already open', defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
}, },
copyAddress: { gameAlreadyOpen: {
id: 'instance.worlds.copy_address', id: 'instance.worlds.game_already_open',
defaultMessage: 'Copy address', defaultMessage: 'Instance is already open',
}, },
viewInstance: { noContact: {
id: 'instance.worlds.view_instance', id: 'instance.worlds.no_contact',
defaultMessage: 'View instance', defaultMessage: "Server couldn't be contacted",
}, },
playAnyway: { incompatibleServer: {
id: 'instance.worlds.play_anyway', id: 'instance.worlds.incompatible_server',
defaultMessage: 'Play anyway', defaultMessage: 'Server is incompatible',
}, },
playInstance: { copyAddress: {
id: 'instance.worlds.play_instance', id: 'instance.worlds.copy_address',
defaultMessage: 'Play instance', defaultMessage: 'Copy address',
}, },
worldInUse: { viewInstance: {
id: 'instance.worlds.world_in_use', id: 'instance.worlds.view_instance',
defaultMessage: 'World is in use', defaultMessage: 'View instance',
}, },
dontShowOnHome: { playInstance: {
id: 'instance.worlds.dont_show_on_home', id: 'instance.worlds.play_instance',
defaultMessage: `Don't show on Home`, defaultMessage: 'Play instance',
}, },
worldInUse: {
id: 'instance.worlds.world_in_use',
defaultMessage: 'World is in use',
},
dontShowOnHome: {
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
}) })
</script> </script>
<template> <template>
<SmartClickable> <SmartClickable>
<template v-if="instancePath" #clickable> <template v-if="instancePath" #clickable>
<router-link <router-link
class="no-click-animation" class="no-click-animation"
:to="`/instance/${encodeURIComponent(instancePath)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`" :to="`/instance/${encodeURIComponent(instancePath)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
/> />
</template> </template>
<div <div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised smart-clickable:highlight-on-hover rounded-xl" class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised smart-clickable:highlight-on-hover rounded-xl"
:class="{ :class="{
'world-item-highlighted': highlighted, 'world-item-highlighted': highlighted,
}" }"
> >
<Avatar <Avatar
:src=" :src="
world.type === 'server' && serverStatus ? serverStatus.favicon ?? world.icon : world.icon world.type === 'server' && serverStatus ? serverStatus.favicon ?? world.icon : world.icon
" "
size="48px" size="48px"
/> />
<div class="flex flex-col justify-between h-full"> <div class="flex flex-col justify-between h-full">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover"> <div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ world.name }} {{ world.name }}
</div> </div>
<div <div
v-if="world.type === 'singleplayer'" v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold" class="text-sm text-secondary flex items-center gap-1 font-semibold"
> >
<UserIcon <UserIcon
aria-hidden="true" aria-hidden="true"
class="h-4 w-4 text-secondary shrink-0" class="h-4 w-4 text-secondary shrink-0"
stroke-width="3px" stroke-width="3px"
/> />
{{ formatMessage(commonMessages.singleplayerLabel) }} {{ formatMessage(commonMessages.singleplayerLabel) }}
</div> </div>
<div <div
v-else-if="world.type === 'server'" v-else-if="world.type === 'server'"
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap" class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
> >
<template v-if="refreshing"> <template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" /> <SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
Loading... Loading...
</template> </template>
<template v-else-if="serverStatus"> <template v-else-if="serverStatus">
<template v-if="serverIncompatible"> <template v-if="serverIncompatible">
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" /> <IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
<span class="text-orange"> <span class="text-orange">
Incompatible version {{ serverStatus.version?.name }} Incompatible version {{ serverStatus.version?.name }}
</span> </span>
</template> </template>
<template v-else> <template v-else>
<SignalIcon <SignalIcon
v-tooltip="serverStatus ? `${serverStatus.ping}ms` : null" v-tooltip="serverStatus ? `${serverStatus.ping}ms` : null"
aria-hidden="true" aria-hidden="true"
:style="`--_signal-${getPingLevel(serverStatus.ping || 0)}: var(--color-green)`" :style="`--_signal-${getPingLevel(serverStatus.ping || 0)}: var(--color-green)`"
stroke-width="3px" stroke-width="3px"
class="shrink-0" class="shrink-0"
:class="{ :class="{
'smart-clickable:allow-pointer-events': serverStatus, 'smart-clickable:allow-pointer-events': serverStatus,
}" }"
/> />
<Tooltip :disabled="!hasPlayersTooltip"> <Tooltip :disabled="!hasPlayersTooltip">
<span :class="{ 'cursor-help': hasPlayersTooltip }"> <span :class="{ 'cursor-help': hasPlayersTooltip }">
{{ formatNumber(serverStatus.players?.online, false) }} online {{ formatNumber(serverStatus.players?.online, false) }}
</span> online
<template #popper> </span>
<div class="flex flex-col gap-1"> <template #popper>
<span v-for="player in serverStatus.players?.sample" :key="player.name"> <div class="flex flex-col gap-1">
{{ player.name }} <span v-for="player in serverStatus.players?.sample" :key="player.name">
</span> {{ player.name }}
</div> </span>
</template> </div>
</Tooltip> </template>
</template> </Tooltip>
</template> </template>
<template v-else> </template>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" /> Offline <template v-else>
</template> <NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
</div> Offline
</div> </template>
<div class="flex items-center gap-2 text-sm text-secondary"> </div>
<div </div>
v-tooltip=" <div class="flex items-center gap-2 text-sm text-secondary">
world.last_played ? dayjs(world.last_played).format('MMMM D, YYYY [at] h:mm A') : null <div
" v-tooltip="
class="w-fit shrink-0" world.last_played ? dayjs(world.last_played).format('MMMM D, YYYY [at] h:mm A') : null
:class="{ 'cursor-help smart-clickable:allow-pointer-events': world.last_played }" "
> class="w-fit shrink-0"
<template v-if="world.last_played"> :class="{
{{ 'cursor-help smart-clickable:allow-pointer-events': world.last_played,
formatMessage(commonMessages.playedLabel, { }"
time: formatRelativeTime(dayjs(world.last_played).toISOString()), >
}) <template v-if="world.last_played">
}} {{
</template> formatMessage(commonMessages.playedLabel, {
<template v-else> Not played yet </template> time: formatRelativeTime(dayjs(world.last_played).toISOString()),
</div> })
<template v-if="instancePath"> }}
</template>
<router-link <template v-else> Not played yet </template>
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events" </div>
:to="`/instance/${instancePath}`" <template v-if="instancePath">
>
<Avatar <router-link
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined" class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
size="16px" :to="`/instance/${instancePath}`"
:tint-by="instancePath" >
class="shrink-0" <Avatar
/> :src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
<span class="truncate">{{ instanceName }}</span> size="16px"
</router-link> :tint-by="instancePath"
</template> class="shrink-0"
</div> />
</div> <span class="truncate">{{ instanceName }}</span>
<div </router-link>
class="font-semibold flex items-center gap-1 justify-center text-center" </template>
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'" </div>
> </div>
<template v-if="world.type === 'server'"> <div
<template v-if="refreshing"> class="font-semibold flex items-center gap-1 justify-center text-center"
<SpinnerIcon aria-hidden="true" class="animate-spin" /> :class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
{{ formatMessage(commonMessages.loadingLabel) }} >
</template> <template v-if="world.type === 'server'">
<div <template v-if="refreshing">
v-else-if="renderedMotd" <SpinnerIcon aria-hidden="true" class="animate-spin" />
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5" {{ formatMessage(commonMessages.loadingLabel) }}
v-html="renderedMotd" </template>
/> <div
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5"> v-else-if="renderedMotd"
{{ formatMessage(messages.cantConnect) }} class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
</div> v-html="renderedMotd"
<div v-else class="font-normal font-minecraft text-secondary leading-5"> />
{{ formatMessage(messages.aMinecraftServer) }} <div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
</div> {{ formatMessage(messages.cantConnect) }}
</template> </div>
<template v-else-if="world.type === 'singleplayer' && gameMode"> <div v-else class="font-normal font-minecraft text-secondary leading-5">
<template v-if="world.hardcore"> {{ formatMessage(messages.aMinecraftServer) }}
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" /> </div>
{{ formatMessage(messages.hardcore) }} </template>
</template> <template v-else-if="world.type === 'singleplayer' && gameMode">
<template v-else> <template v-if="world.hardcore">
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" /> <SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
{{ formatMessage(gameMode.message) }} {{ formatMessage(messages.hardcore) }}
</template> </template>
</template> <template v-else>
</div> <component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events"> {{ formatMessage(gameMode.message) }}
<template v-if="world.type === 'singleplayer' || serverStatus"> </template>
<ButtonStyled </template>
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance" </div>
color="red" <div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
> <ButtonStyled
<button @click="emit('stop')"> v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
<StopCircleIcon aria-hidden="true" /> color="red"
{{ formatMessage(commonMessages.stopButton) }} >
</button> <button @click="emit('stop')">
</ButtonStyled> <StopCircleIcon aria-hidden="true" />
<ButtonStyled v-else> {{ formatMessage(commonMessages.stopButton) }}
<button </button>
v-tooltip=" </ButtonStyled>
serverIncompatible <ButtonStyled v-else>
? 'Server is incompatible' <button
: !supportsQuickPlay v-tooltip="
? formatMessage(messages.noQuickPlay) world.type == 'server' && !supportsServerQuickPlay
: playingOtherWorld || locked ? formatMessage(messages.noServerQuickPlay)
? formatMessage(messages.gameAlreadyOpen) : world.type == 'singleplayer' && !supportsWorldQuickPlay
: null ? formatMessage(messages.noSingleplayerQuickPlay)
" : playingOtherWorld || locked
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance" ? formatMessage(messages.gameAlreadyOpen)
@click="emit('play')" : !serverStatus
> ? formatMessage(messages.noContact)
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" /> : serverIncompatible
<PlayIcon v-else aria-hidden="true" /> ? formatMessage(messages.incompatibleServer)
{{ formatMessage(commonMessages.playButton) }} : null
</button> "
</ButtonStyled> :disabled="
</template> playingOtherWorld ||
<ButtonStyled v-else> startingInstance ||
<button class="invisible"> (world.type == 'server' && !supportsServerQuickPlay) ||
<PlayIcon aria-hidden="true" /> (world.type == 'singleplayer' && !supportsWorldQuickPlay)
{{ formatMessage(commonMessages.playButton) }} "
</button> @click="emit('play')"
</ButtonStyled> >
<ButtonStyled circular type="transparent"> <SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<OverflowMenu <PlayIcon v-else aria-hidden="true" />
:options="[ {{ formatMessage(commonMessages.playButton) }}
{ </button>
id: 'play-instance', </ButtonStyled>
shown: !!instancePath, <ButtonStyled circular type="transparent">
disabled: playingInstance, <OverflowMenu
action: () => emit('play-instance'), :options="[
}, {
{ id: 'play-instance',
id: 'play-anyway', shown: !!instancePath,
shown: serverIncompatible && !playingInstance && supportsQuickPlay, disabled: playingInstance,
action: () => emit('play'), action: () => emit('play-instance'),
}, },
{ {
id: 'open-instance', id: 'open-instance',
shown: !!instancePath, shown: !!instancePath,
action: () => router.push(encodeURI(`/instance/${instancePath}`)), action: () => router.push(encodeURI(`/instance/${instancePath}`)),
}, },
{ {
id: 'refresh', id: 'refresh',
shown: world.type === 'server', shown: world.type === 'server',
action: () => emit('refresh'), action: () => emit('refresh'),
}, },
{ {
id: 'copy-address', id: 'copy-address',
shown: world.type === 'server', shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address), action: () => copyToClipboard((world as ServerWorld).address),
}, },
{ {
id: 'edit', id: 'edit',
action: () => emit('edit'), action: () => emit('edit'),
shown: !instancePath, shown: !instancePath,
disabled: locked, disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined, tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
}, },
{ {
id: 'open-folder', id: 'open-folder',
shown: world.type === 'singleplayer', shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}), action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
}, },
{ {
divider: true, divider: true,
shown: !!instancePath, shown: !!instancePath,
}, },
{ {
id: 'dont-show-on-home', id: 'dont-show-on-home',
shown: !!instancePath, shown: !!instancePath,
action: () => { action: () => {
set_world_display_status( set_world_display_status(
instancePath, instancePath,
world.type, world.type,
getWorldIdentifier(world), getWorldIdentifier(world),
'hidden', 'hidden',
).then(() => { ).then(() => {
emit('update') emit('update')
}) })
}, },
}, },
{ {
divider: true, divider: true,
shown: !instancePath, shown: !instancePath,
}, },
{ {
id: 'delete', id: 'delete',
color: 'red', color: 'red',
hoverFilled: true, hoverFilled: true,
action: () => emit('delete'), action: () => emit('delete'),
shown: !instancePath, shown: !instancePath,
disabled: locked, disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined, tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
}, },
]" ]"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #play-instance> <template #play-instance>
<PlayIcon aria-hidden="true" /> <PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }} {{ formatMessage(messages.playInstance) }}
</template> </template>
<template #play-anyway> <template #open-instance>
<PlayIcon aria-hidden="true" /> <EyeIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }} {{ formatMessage(messages.viewInstance) }}
</template> </template>
<template #open-instance> <template #edit>
<EyeIcon aria-hidden="true" /> <EditIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }} {{ formatMessage(commonMessages.editButton) }}
</template> </template>
<template #edit> <template #open-folder>
<EditIcon aria-hidden="true" /> {{ formatMessage(commonMessages.editButton) }} <FolderOpenIcon aria-hidden="true" />
</template> {{ formatMessage(commonMessages.openFolderButton) }}
<template #open-folder> </template>
<FolderOpenIcon aria-hidden="true" /> <template #copy-address>
{{ formatMessage(commonMessages.openFolderButton) }} <ClipboardCopyIcon aria-hidden="true" />
</template> {{ formatMessage(messages.copyAddress) }}
<template #copy-address> </template>
<ClipboardCopyIcon aria-hidden="true" /> {{ formatMessage(messages.copyAddress) }} <template #refresh>
</template> <UpdatedIcon aria-hidden="true" />
<template #refresh> {{ formatMessage(commonMessages.refreshButton) }}
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }} </template>
</template> <template #dont-show-on-home>
<template #dont-show-on-home> <XIcon aria-hidden="true" />
<XIcon aria-hidden="true" /> {{ formatMessage(messages.dontShowOnHome) }}
{{ formatMessage(messages.dontShowOnHome) }} </template>
</template> <template #delete>
<template #delete> <TrashIcon aria-hidden="true" />
<TrashIcon aria-hidden="true" /> {{
{{ formatMessage(
formatMessage( world.type === 'server'
world.type === 'server' ? commonMessages.removeButton
? commonMessages.removeButton : commonMessages.deleteLabel,
: commonMessages.deleteLabel, )
) }}
}} </template>
</template> </OverflowMenu>
</OverflowMenu> </ButtonStyled>
</ButtonStyled> </div>
</div> </div>
</div> </SmartClickable>
</SmartClickable>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.world-item-highlighted { .world-item-highlighted {
position: relative; position: relative;
animation: fade-highlight 4s ease-out; animation: fade-highlight 4s ease-out;
filter: brightness(1); filter: brightness(1);
&::before { &::before {
@apply rounded-xl inset-0 absolute; @apply rounded-xl inset-0 absolute;
animation: fade-opacity 4s ease-out; animation: fade-opacity 4s ease-out;
content: ''; content: '';
box-shadow: 0 0 8px 2px var(--color-brand); box-shadow: 0 0 8px 2px var(--color-brand);
border: 1.5px solid var(--color-brand); border: 1.5px solid var(--color-brand);
opacity: 0; opacity: 0;
} }
} }
@keyframes fade-highlight { @keyframes fade-highlight {
0% { 0% {
filter: brightness(1.25); filter: brightness(1.25);
} }
75% { 75% {
filter: brightness(1.25); filter: brightness(1.25);
} }
100% { 100% {
filter: brightness(1); filter: brightness(1);
} }
} }
@keyframes fade-opacity { @keyframes fade-opacity {
0% { 0% {
opacity: 0.5; opacity: 0.5;
} }
75% { 75% {
opacity: 0.5; opacity: 0.5;
} }
100% { 100% {
opacity: 0; opacity: 0;
} }
} }
.light-mode .motd-renderer { .light-mode .motd-renderer {
filter: brightness(0.75); filter: brightness(0.75);
} }
</style> </style>

View File

@@ -1,23 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets' import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui' import { ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications' import { ref } from 'vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{
submit: [server: ServerWorld, play: boolean] submit: [server: ServerWorld, play: boolean]
}>() }>()
const props = defineProps<{ const props = defineProps<{
instance: GameInstance instance: GameInstance
}>() }>()
const modal = ref() const modal = ref()
@@ -27,89 +28,89 @@ const address = ref()
const resourcePack = ref<ServerPackStatus>('enabled') const resourcePack = ref<ServerPackStatus>('enabled')
async function addServer(play: boolean) { async function addServer(play: boolean) {
const serverName = name.value ? name.value : address.value const serverName = name.value ? name.value : address.value
const resourcePackStatus = resourcePack.value const resourcePackStatus = resourcePack.value
const index = const index =
(await add_server_to_profile( (await add_server_to_profile(
props.instance.path, props.instance.path,
serverName, serverName,
address.value, address.value,
resourcePackStatus, resourcePackStatus,
).catch(handleError)) ?? 0 ).catch(handleError)) ?? 0
emit( emit(
'submit', 'submit',
{ {
name: serverName, name: serverName,
type: 'server', type: 'server',
index, index,
address: address.value, address: address.value,
pack_status: resourcePackStatus, pack_status: resourcePackStatus,
}, },
play, play,
) )
hide() hide()
} }
function show() { function show() {
name.value = '' name.value = ''
address.value = '' address.value = ''
resourcePack.value = 'enabled' resourcePack.value = 'enabled'
modal.value.show() modal.value.show()
} }
function hide() { function hide() {
modal.value.hide() modal.value.hide()
} }
const messages = defineMessages({ const messages = defineMessages({
title: { title: {
id: 'instance.add-server.title', id: 'instance.add-server.title',
defaultMessage: 'Add a server', defaultMessage: 'Add a server',
}, },
addServer: { addServer: {
id: 'instance.add-server.add-server', id: 'instance.add-server.add-server',
defaultMessage: 'Add server', defaultMessage: 'Add server',
}, },
addAndPlay: { addAndPlay: {
id: 'instance.add-server.add-and-play', id: 'instance.add-server.add-and-play',
defaultMessage: 'Add and play', defaultMessage: 'Add and play',
}, },
}) })
defineExpose({ show, hide }) defineExpose({ show, hide })
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <ModalWrapper ref="modal">
<template #title> <template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary"> <span class="flex items-center gap-2 text-lg font-semibold text-primary">
<InstanceModalTitlePrefix :instance="instance" /> <InstanceModalTitlePrefix :instance="instance" />
<span class="font-extrabold text-contrast">{{ formatMessage(messages.title) }}</span> <span class="font-extrabold text-contrast">{{ formatMessage(messages.title) }}</span>
</span> </span>
</template> </template>
<ServerModalBody <ServerModalBody
v-model:name="name" v-model:name="name"
v-model:address="address" v-model:address="address"
v-model:resource-pack="resourcePack" v-model:resource-pack="resourcePack"
/> />
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="!address" @click="addServer(true)"> <button :disabled="!address" @click="addServer(true)">
<PlayIcon /> <PlayIcon />
{{ formatMessage(messages.addAndPlay) }} {{ formatMessage(messages.addAndPlay) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button :disabled="!address" @click="addServer(false)"> <button :disabled="!address" @click="addServer(false)">
<PlusIcon /> <PlusIcon />
{{ formatMessage(messages.addServer) }} {{ formatMessage(messages.addServer) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button @click="hide()"> <button @click="hide()">
<XIcon /> <XIcon />
{{ formatMessage(commonMessages.cancelButton) }} {{ formatMessage(commonMessages.cancelButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -1,29 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets' import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui' import { ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
type ServerPackStatus, type DisplayStatus,
edit_server_in_profile, edit_server_in_profile,
type ServerWorld, type ServerPackStatus,
set_world_display_status, type ServerWorld,
type DisplayStatus, set_world_display_status,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{
submit: [server: ServerWorld] submit: [server: ServerWorld]
}>() }>()
const props = defineProps<{ const props = defineProps<{
instance: GameInstance instance: GameInstance
}>() }>()
const modal = ref() const modal = ref()
@@ -38,81 +39,81 @@ const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal')) const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveServer() { async function saveServer() {
const serverName = name.value ? name.value : address.value const serverName = name.value ? name.value : address.value
const resourcePackStatus = resourcePack.value const resourcePackStatus = resourcePack.value
await edit_server_in_profile( await edit_server_in_profile(
props.instance.path, props.instance.path,
index.value, index.value,
serverName, serverName,
address.value, address.value,
resourcePackStatus, resourcePackStatus,
).catch(handleError) ).catch(handleError)
if (newDisplayStatus.value !== displayStatus.value) { if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status( await set_world_display_status(
props.instance.path, props.instance.path,
'server', 'server',
address.value, address.value,
newDisplayStatus.value, newDisplayStatus.value,
).catch(handleError) ).catch(handleError)
} }
emit('submit', { emit('submit', {
name: serverName, name: serverName,
type: 'server', type: 'server',
index: index.value, index: index.value,
address: address.value, address: address.value,
pack_status: resourcePackStatus, pack_status: resourcePackStatus,
display_status: newDisplayStatus.value, display_status: newDisplayStatus.value,
}) })
hide() hide()
} }
function show(server: ServerWorld) { function show(server: ServerWorld) {
name.value = server.name name.value = server.name
address.value = server.address address.value = server.address
resourcePack.value = server.pack_status resourcePack.value = server.pack_status
index.value = server.index index.value = server.index
displayStatus.value = server.display_status displayStatus.value = server.display_status
hideFromHome.value = server.display_status === 'hidden' hideFromHome.value = server.display_status === 'hidden'
modal.value.show() modal.value.show()
} }
function hide() { function hide() {
modal.value.hide() modal.value.hide()
} }
defineExpose({ show }) defineExpose({ show })
const titleMessage = defineMessage({ const titleMessage = defineMessage({
id: 'instance.edit-server.title', id: 'instance.edit-server.title',
defaultMessage: 'Edit server', defaultMessage: 'Edit server',
}) })
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <ModalWrapper ref="modal">
<template #title> <template #title>
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(titleMessage) }}</span> <span class="font-extrabold text-lg text-contrast">{{ formatMessage(titleMessage) }}</span>
</template> </template>
<ServerModalBody <ServerModalBody
v-model:name="name" v-model:name="name"
v-model:address="address" v-model:address="address"
v-model:resource-pack="resourcePack" v-model:resource-pack="resourcePack"
/> />
<HideFromHomeOption v-model="hideFromHome" class="mt-3" /> <HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer"> <button :disabled="!address" @click="saveServer">
<SaveIcon /> <SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }} {{ formatMessage(commonMessages.saveChangesButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button @click="hide()"> <button @click="hide()">
<XIcon /> <XIcon />
{{ formatMessage(commonMessages.cancelButton) }} {{ formatMessage(commonMessages.cancelButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -1,23 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets' import { ChevronRightIcon, SaveIcon, UndoIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui' import { Avatar, ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts' import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts'
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts' import { rename_world, reset_world_icon, set_world_display_status } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{
submit: [path: string, name: string, removeIcon: boolean, displayStatus: DisplayStatus] submit: [path: string, name: string, removeIcon: boolean, displayStatus: DisplayStatus]
}>() }>()
const props = defineProps<{ const props = defineProps<{
instance: GameInstance instance: GameInstance
}>() }>()
const modal = ref() const modal = ref()
@@ -32,98 +33,98 @@ const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal')) const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveWorld() { async function saveWorld() {
await rename_world(props.instance.path, path.value, name.value).catch(handleError) await rename_world(props.instance.path, path.value, name.value).catch(handleError)
if (removeIcon.value) { if (removeIcon.value) {
await reset_world_icon(props.instance.path, path.value).catch(handleError) await reset_world_icon(props.instance.path, path.value).catch(handleError)
} }
if (newDisplayStatus.value !== displayStatus.value) { if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status( await set_world_display_status(
props.instance.path, props.instance.path,
'singleplayer', 'singleplayer',
path.value, path.value,
newDisplayStatus.value, newDisplayStatus.value,
) )
} }
emit('submit', path.value, name.value, removeIcon.value, newDisplayStatus.value) emit('submit', path.value, name.value, removeIcon.value, newDisplayStatus.value)
hide() hide()
} }
function show(world: SingleplayerWorld) { function show(world: SingleplayerWorld) {
name.value = world.name name.value = world.name
path.value = world.path path.value = world.path
icon.value = world.icon icon.value = world.icon
displayStatus.value = world.display_status displayStatus.value = world.display_status
hideFromHome.value = world.display_status === 'hidden' hideFromHome.value = world.display_status === 'hidden'
removeIcon.value = false removeIcon.value = false
modal.value.show() modal.value.show()
} }
function hide() { function hide() {
modal.value.hide() modal.value.hide()
} }
defineExpose({ show }) defineExpose({ show })
const messages = defineMessages({ const messages = defineMessages({
title: { title: {
id: 'instance.edit-world.title', id: 'instance.edit-world.title',
defaultMessage: 'Edit world', defaultMessage: 'Edit world',
}, },
name: { name: {
id: 'instance.edit-world.name', id: 'instance.edit-world.name',
defaultMessage: 'Name', defaultMessage: 'Name',
}, },
placeholderName: { placeholderName: {
id: 'instance.edit-world.placeholder-name', id: 'instance.edit-world.placeholder-name',
defaultMessage: 'Minecraft World', defaultMessage: 'Minecraft World',
}, },
resetIcon: { resetIcon: {
id: 'instance.edit-world.reset-icon', id: 'instance.edit-world.reset-icon',
defaultMessage: 'Reset icon', defaultMessage: 'Reset icon',
}, },
}) })
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <ModalWrapper ref="modal">
<template #title> <template #title>
<Avatar :src="removeIcon || !icon ? undefined : icon" size="24px" /> <Avatar :src="removeIcon || !icon ? undefined : icon" size="24px" />
{{ instance.name }} <ChevronRightIcon /> {{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(messages.title) }}</span> <span class="font-extrabold text-lg text-contrast">{{ formatMessage(messages.title) }}</span>
</template> </template>
<div class="w-[450px]"> <div class="w-[450px]">
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1"> <h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }} {{ formatMessage(messages.name) }}
</h2> </h2>
<input <input
v-model="name" v-model="name"
type="text" type="text"
:placeholder="formatMessage(messages.placeholderName)" :placeholder="formatMessage(messages.placeholderName)"
class="w-full" class="w-full"
autocomplete="off" autocomplete="off"
/> />
<HideFromHomeOption v-model="hideFromHome" class="mt-3" /> <HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div> </div>
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button @click="saveWorld"> <button @click="saveWorld">
<SaveIcon /> <SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }} {{ formatMessage(commonMessages.saveChangesButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button :disabled="removeIcon || !icon" @click="removeIcon = true"> <button :disabled="removeIcon || !icon" @click="removeIcon = true">
<UndoIcon /> <UndoIcon />
{{ formatMessage(messages.resetIcon) }} {{ formatMessage(messages.resetIcon) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button @click="hide()"> <button @click="hide()">
<XIcon /> <XIcon />
{{ formatMessage(commonMessages.cancelButton) }} {{ formatMessage(commonMessages.cancelButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -1,18 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { Checkbox } from '@modrinth/ui'
import { defineMessage, useVIntl } from '@vintl/vintl' import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed } from 'vue' import { computed } from 'vue'
import { Checkbox } from '@modrinth/ui'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const value = defineModel<boolean>({ required: true }) const value = defineModel<boolean>({ required: true })
const labelMessage = defineMessage({ const labelMessage = defineMessage({
id: 'instance.edit-world.hide-from-home', id: 'instance.edit-world.hide-from-home',
defaultMessage: `Hide from the Home page`, defaultMessage: `Hide from the Home page`,
}) })
const label = computed(() => formatMessage(labelMessage)) const label = computed(() => formatMessage(labelMessage))
</script> </script>
<template> <template>
<Checkbox v-model="value" :label="label" /> <Checkbox v-model="value" :label="label" />
</template> </template>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { TeleportDropdownMenu } from '@modrinth/ui' import { TeleportDropdownMenu } from '@modrinth/ui'
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import type { ServerPackStatus } from '@/helpers/worlds.ts' import type { ServerPackStatus } from '@/helpers/worlds.ts'
import { type MessageDescriptor, defineMessages, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -12,75 +13,75 @@ const resourcePack = defineModel<ServerPackStatus>('resourcePack')
const resourcePackOptions: ServerPackStatus[] = ['enabled', 'prompt', 'disabled'] const resourcePackOptions: ServerPackStatus[] = ['enabled', 'prompt', 'disabled']
const resourcePackOptionMessages: Record<ServerPackStatus, MessageDescriptor> = defineMessages({ const resourcePackOptionMessages: Record<ServerPackStatus, MessageDescriptor> = defineMessages({
enabled: { enabled: {
id: 'instance.add-server.resource-pack.enabled', id: 'instance.add-server.resource-pack.enabled',
defaultMessage: 'Enabled', defaultMessage: 'Enabled',
}, },
prompt: { prompt: {
id: 'instance.add-server.resource-pack.prompt', id: 'instance.add-server.resource-pack.prompt',
defaultMessage: 'Prompt', defaultMessage: 'Prompt',
}, },
disabled: { disabled: {
id: 'instance.add-server.resource-pack.disabled', id: 'instance.add-server.resource-pack.disabled',
defaultMessage: 'Disabled', defaultMessage: 'Disabled',
}, },
}) })
const messages = defineMessages({ const messages = defineMessages({
name: { name: {
id: 'instance.server-modal.name', id: 'instance.server-modal.name',
defaultMessage: 'Name', defaultMessage: 'Name',
}, },
address: { address: {
id: 'instance.server-modal.address', id: 'instance.server-modal.address',
defaultMessage: 'Address', defaultMessage: 'Address',
}, },
resourcePack: { resourcePack: {
id: 'instance.server-modal.resource-pack', id: 'instance.server-modal.resource-pack',
defaultMessage: 'Resource pack', defaultMessage: 'Resource pack',
}, },
placeholderName: { placeholderName: {
id: 'instance.server-modal.placeholder-name', id: 'instance.server-modal.placeholder-name',
defaultMessage: 'Minecraft Server', defaultMessage: 'Minecraft Server',
}, },
}) })
defineExpose({ resourcePackOptions }) defineExpose({ resourcePackOptions })
</script> </script>
<template> <template>
<div class="w-[450px]"> <div class="w-[450px]">
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1"> <h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }} {{ formatMessage(messages.name) }}
</h2> </h2>
<input <input
v-model="name" v-model="name"
type="text" type="text"
:placeholder="formatMessage(messages.placeholderName)" :placeholder="formatMessage(messages.placeholderName)"
class="w-full" class="w-full"
autocomplete="off" autocomplete="off"
/> />
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1"> <h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.address) }} {{ formatMessage(messages.address) }}
</h2> </h2>
<input <input
v-model="address" v-model="address"
type="text" type="text"
placeholder="example.modrinth.gg" placeholder="example.modrinth.gg"
class="w-full" class="w-full"
autocomplete="off" autocomplete="off"
/> />
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1"> <h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.resourcePack) }} {{ formatMessage(messages.resourcePack) }}
</h2> </h2>
<div> <div>
<TeleportDropdownMenu <TeleportDropdownMenu
v-model="resourcePack" v-model="resourcePack"
:options="resourcePackOptions" :options="resourcePackOptions"
name="Server resource pack" name="Server resource pack"
:display-name=" :display-name="
(option: ServerPackStatus) => formatMessage(resourcePackOptionMessages[option]) (option: ServerPackStatus) => formatMessage(resourcePackOptionMessages[option])
" "
/> />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,20 +1,21 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import cssContent from '@/assets/stylesheets/macFix.css?inline' import cssContent from '@/assets/stylesheets/macFix.css?inline'
export async function useCheckDisableMouseover() { export async function useCheckDisableMouseover() {
try { try {
// Fetch the CSS content from the Rust backend // Fetch the CSS content from the Rust backend
let should_disable_mouseover = await invoke('plugin:utils|should_disable_mouseover') let should_disable_mouseover = await invoke('plugin:utils|should_disable_mouseover')
if (should_disable_mouseover) { if (should_disable_mouseover) {
// Create a style element and set its content // Create a style element and set its content
const styleElement = document.createElement('style') const styleElement = document.createElement('style')
styleElement.innerHTML = cssContent styleElement.innerHTML = cssContent
// Append the style element to the document's head // Append the style element to the document's head
document.head.appendChild(styleElement) document.head.appendChild(styleElement)
} }
} catch (error) { } catch (error) {
console.error('Error checking OS version from Rust backend', error) console.error('Error checking OS version from Rust backend', error)
} }
} }

View File

@@ -0,0 +1,21 @@
import { computed, ref } from 'vue'
import { get_max_memory } from '@/helpers/jre.js'
export default async function () {
const maxMemory = ref(Math.floor((await get_max_memory()) / 1024))
const snapPoints = computed(() => {
let points = []
let memory = 2048
while (memory <= maxMemory.value) {
points.push(memory)
memory *= 2
}
return points
})
return { maxMemory, snapPoints }
}

View File

@@ -1,24 +1,24 @@
import { posthog } from 'posthog-js' import { posthog } from 'posthog-js'
export const initAnalytics = () => { export const initAnalytics = () => {
posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', { posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
persistence: 'localStorage', persistence: 'localStorage',
api_host: 'https://posthog.modrinth.com', api_host: 'https://posthog.modrinth.com',
}) })
} }
export const debugAnalytics = () => { export const debugAnalytics = () => {
posthog.debug() posthog.debug()
} }
export const optOutAnalytics = () => { export const optOutAnalytics = () => {
posthog.opt_out_capturing() posthog.opt_out_capturing()
} }
export const optInAnalytics = () => { export const optInAnalytics = () => {
posthog.opt_in_capturing() posthog.opt_in_capturing()
} }
export const trackEvent = (eventName, properties) => { export const trackEvent = (eventName, properties) => {
posthog.capture(eventName, properties) posthog.capture(eventName, properties)
} }

View File

@@ -17,6 +17,24 @@ export async function offline_login(name) {
return await invoke('plugin:auth|offline_login', { name: name }) return await invoke('plugin:auth|offline_login', { name: name })
} }
// [AR] • Feature
export async function elyby_login(uuid, login, accessToken) {
return await invoke('plugin:auth|elyby_login', {
uuid,
login,
accessToken
})
}
// [AR] • Feature
export async function elyby_auth_authenticate(login, password, clientToken) {
return await invoke('plugin:auth|elyby_auth_authenticate', {
login,
password,
clientToken,
})
}
/** /**
* Authenticate a user with Hydra - part 1. * Authenticate a user with Hydra - part 1.
* This begins the authentication flow quasi-synchronously. * This begins the authentication flow quasi-synchronously.
@@ -26,7 +44,7 @@ export async function offline_login(name) {
* @property {string} user_code - The code to enter on the verification_uri page. * @property {string} user_code - The code to enter on the verification_uri page.
*/ */
export async function login() { export async function login() {
return await invoke('plugin:auth|login') return await invoke('plugin:auth|login')
} }
/** /**
@@ -34,7 +52,7 @@ export async function login() {
* @return {Promise<UUID | undefined>} * @return {Promise<UUID | undefined>}
*/ */
export async function get_default_user() { export async function get_default_user() {
return await invoke('plugin:auth|get_default_user') return await invoke('plugin:auth|get_default_user')
} }
/** /**
@@ -42,7 +60,7 @@ export async function get_default_user() {
* @param {UUID} user * @param {UUID} user
*/ */
export async function set_default_user(user) { export async function set_default_user(user) {
return await invoke('plugin:auth|set_default_user', { user }) return await invoke('plugin:auth|set_default_user', { user })
} }
/** /**
@@ -50,7 +68,7 @@ export async function set_default_user(user) {
* @param {UUID} user * @param {UUID} user
*/ */
export async function remove_user(user) { export async function remove_user(user) {
return await invoke('plugin:auth|remove_user', { user }) return await invoke('plugin:auth|remove_user', { user })
} }
/** /**
@@ -58,5 +76,5 @@ export async function remove_user(user) {
* @returns {Promise<Credential[]>} * @returns {Promise<Credential[]>}
*/ */
export async function users() { export async function users() {
return await invoke('plugin:auth|get_users') return await invoke('plugin:auth|get_users')
} }

View File

@@ -1,53 +1,53 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
export async function get_project(id, cacheBehaviour) { export async function get_project(id, cacheBehaviour) {
return await invoke('plugin:cache|get_project', { id, cacheBehaviour }) return await invoke('plugin:cache|get_project', { id, cacheBehaviour })
} }
export async function get_project_many(ids, cacheBehaviour) { export async function get_project_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_project_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_project_many', { ids, cacheBehaviour })
} }
export async function get_version(id, cacheBehaviour) { export async function get_version(id, cacheBehaviour) {
return await invoke('plugin:cache|get_version', { id, cacheBehaviour }) return await invoke('plugin:cache|get_version', { id, cacheBehaviour })
} }
export async function get_version_many(ids, cacheBehaviour) { export async function get_version_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_version_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_version_many', { ids, cacheBehaviour })
} }
export async function get_user(id, cacheBehaviour) { export async function get_user(id, cacheBehaviour) {
return await invoke('plugin:cache|get_user', { id, cacheBehaviour }) return await invoke('plugin:cache|get_user', { id, cacheBehaviour })
} }
export async function get_user_many(ids, cacheBehaviour) { export async function get_user_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_user_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_user_many', { ids, cacheBehaviour })
} }
export async function get_team(id, cacheBehaviour) { export async function get_team(id, cacheBehaviour) {
return await invoke('plugin:cache|get_team', { id, cacheBehaviour }) return await invoke('plugin:cache|get_team', { id, cacheBehaviour })
} }
export async function get_team_many(ids, cacheBehaviour) { export async function get_team_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_team_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_team_many', { ids, cacheBehaviour })
} }
export async function get_organization(id, cacheBehaviour) { export async function get_organization(id, cacheBehaviour) {
return await invoke('plugin:cache|get_organization', { id, cacheBehaviour }) return await invoke('plugin:cache|get_organization', { id, cacheBehaviour })
} }
export async function get_organization_many(ids, cacheBehaviour) { export async function get_organization_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_organization_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_organization_many', { ids, cacheBehaviour })
} }
export async function get_search_results(id, cacheBehaviour) { export async function get_search_results(id, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results', { id, cacheBehaviour }) return await invoke('plugin:cache|get_search_results', { id, cacheBehaviour })
} }
export async function get_search_results_many(ids, cacheBehaviour) { export async function get_search_results_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_many', { ids, cacheBehaviour }) return await invoke('plugin:cache|get_search_results_many', { ids, cacheBehaviour })
} }
export async function purge_cache_types(cacheTypes) { export async function purge_cache_types(cacheTypes) {
return await invoke('plugin:cache|purge_cache_types', { cacheTypes }) return await invoke('plugin:cache|purge_cache_types', { cacheTypes })
} }

View File

@@ -41,7 +41,7 @@ import { listen } from '@tauri-apps/api/event'
} }
*/ */
export async function loading_listener(callback) { export async function loading_listener(callback) {
return await listen('loading', (event) => callback(event.payload)) return await listen('loading', (event) => callback(event.payload))
} }
/// Payload for the 'process' event /// Payload for the 'process' event
@@ -54,7 +54,7 @@ export async function loading_listener(callback) {
} }
*/ */
export async function process_listener(callback) { export async function process_listener(callback) {
return await listen('process', (event) => callback(event.payload)) return await listen('process', (event) => callback(event.payload))
} }
/// Payload for the 'profile' event /// Payload for the 'profile' event
@@ -68,7 +68,7 @@ export async function process_listener(callback) {
} }
*/ */
export async function profile_listener(callback) { export async function profile_listener(callback) {
return await listen('profile', (event) => callback(event.payload)) return await listen('profile', (event) => callback(event.payload))
} }
/// Payload for the 'command' event /// Payload for the 'command' event
@@ -79,9 +79,9 @@ export async function profile_listener(callback) {
} }
*/ */
export async function command_listener(callback) { export async function command_listener(callback) {
return await listen('command', (event) => { return await listen('command', (event) => {
callback(event.payload) callback(event.payload)
}) })
} }
/// Payload for the 'warning' event /// Payload for the 'warning' event
@@ -91,9 +91,9 @@ export async function command_listener(callback) {
} }
*/ */
export async function warning_listener(callback) { export async function warning_listener(callback) {
return await listen('warning', (event) => callback(event.payload)) return await listen('warning', (event) => callback(event.payload))
} }
export async function friend_listener(callback) { export async function friend_listener(callback) {
return await listen('friend', (event) => callback(event.payload)) return await listen('friend', (event) => callback(event.payload))
} }

View File

@@ -1,18 +1,18 @@
import { fetch } from '@tauri-apps/plugin-http'
import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { fetch } from '@tauri-apps/plugin-http'
export const useFetch = async (url, item, isSilent) => { export const useFetch = async (url, item, isSilent) => {
try { try {
const version = await getVersion() const version = await getVersion()
return await fetch(url, { return await fetch(url, {
method: 'GET', method: 'GET',
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` }, headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
}) })
} catch (err) { } catch (err) {
if (!isSilent) { if (!isSilent) {
handleError({ message: `Error fetching ${item}` }) throw err
} } else {
console.error(err) console.error(err)
} }
}
} }

View File

@@ -1,17 +1,17 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
export async function friends() { export async function friends() {
return await invoke('plugin:friends|friends') return await invoke('plugin:friends|friends')
} }
export async function friend_statuses() { export async function friend_statuses() {
return await invoke('plugin:friends|friend_statuses') return await invoke('plugin:friends|friend_statuses')
} }
export async function add_friend(userId) { export async function add_friend(userId) {
return await invoke('plugin:friends|add_friend', { userId }) return await invoke('plugin:friends|add_friend', { userId })
} }
export async function remove_friend(userId) { export async function remove_friend(userId) {
return await invoke('plugin:friends|remove_friend', { userId }) return await invoke('plugin:friends|remove_friend', { userId })
} }

View File

@@ -4,6 +4,7 @@
* and deserialized into a usable JS object. * and deserialized into a usable JS object.
*/ */
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { create } from './profile' import { create } from './profile'
/* /*
@@ -27,37 +28,65 @@ import { create } from './profile'
/// eg: get_importable_instances("MultiMC", "C:/MultiMC") /// eg: get_importable_instances("MultiMC", "C:/MultiMC")
/// returns ["Instance 1", "Instance 2"] /// returns ["Instance 1", "Instance 2"]
export async function get_importable_instances(launcherType, basePath) { export async function get_importable_instances(launcherType, basePath) {
return await invoke('plugin:import|get_importable_instances', { launcherType, basePath }) return await invoke('plugin:import|get_importable_instances', { launcherType, basePath })
} }
/// Import an instance from a launcher type and base path /// Import an instance from a launcher type and base path
/// eg: import_instance("profile-name-to-go-to", "MultiMC", "C:/MultiMC", "Instance 1") /// eg: import_instance("profile-name-to-go-to", "MultiMC", "C:/MultiMC", "Instance 1")
export async function import_instance(launcherType, basePath, instanceFolder) { export async function import_instance(launcherType, basePath, instanceFolder) {
// create a basic, empty instance (most properties will be filled in by the import process) // create a basic, empty instance (most properties will be filled in by the import process)
// We do NOT watch the fs for changes to avoid duplicate events during installation // We do NOT watch the fs for changes to avoid duplicate events during installation
// fs watching will be enabled once the instance is imported // fs watching will be enabled once the instance is imported
const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null, true) const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null, true)
return await invoke('plugin:import|import_instance', { return await invoke('plugin:import|import_instance', {
profilePath, profilePath,
launcherType, launcherType,
basePath, basePath,
instanceFolder, instanceFolder,
}) })
} }
/// Checks if this instance is valid for importing, given a certain launcher type /// Checks if this instance is valid for importing, given a certain launcher type
/// eg: is_valid_importable_instance("C:/MultiMC/Instance 1", "MultiMC") /// eg: is_valid_importable_instance("C:/MultiMC/Instance 1", "MultiMC")
export async function is_valid_importable_instance(instanceFolder, launcherType) { export async function is_valid_importable_instance(instanceFolder, launcherType) {
return await invoke('plugin:import|is_valid_importable_instance', { return await invoke('plugin:import|is_valid_importable_instance', {
instanceFolder, instanceFolder,
launcherType, launcherType,
}) })
} }
/// Gets the default path for the given launcher type /// Gets the default path for the given launcher type
/// null if it can't be found or doesn't exist /// null if it can't be found or doesn't exist
/// eg: get_default_launcher_path("MultiMC") /// eg: get_default_launcher_path("MultiMC")
export async function get_default_launcher_path(launcherType) { export async function get_default_launcher_path(launcherType) {
return await invoke('plugin:import|get_default_launcher_path', { launcherType }) return await invoke('plugin:import|get_default_launcher_path', { launcherType })
}
/// Fetch CurseForge profile metadata from profile code
/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
export async function fetch_curseforge_profile_metadata(profileCode) {
return await invoke('plugin:import|fetch_curseforge_profile_metadata', { profileCode })
}
/// Import a CurseForge profile from profile code
/// eg: import_curseforge_profile("eSrNlKNo")
export async function import_curseforge_profile(profileCode) {
try {
// First, fetch the profile metadata to get the actual name
const metadata = await fetch_curseforge_profile_metadata(profileCode)
// create a basic, empty instance using the actual profile name
const profilePath = await create(metadata.name, '1.19.4', 'vanilla', 'latest', null, true)
const result = await invoke('plugin:import|import_curseforge_profile', {
profilePath,
profileCode,
})
// Return the profile path for navigation
return { result, profilePath }
} catch (error) {
throw error
}
} }

View File

@@ -15,37 +15,37 @@ JavaVersion {
*/ */
export async function get_java_versions() { export async function get_java_versions() {
return await invoke('plugin:jre|get_java_versions') return await invoke('plugin:jre|get_java_versions')
} }
export async function set_java_version(javaVersion) { export async function set_java_version(javaVersion) {
return await invoke('plugin:jre|set_java_version', { javaVersion }) return await invoke('plugin:jre|set_java_version', { javaVersion })
} }
// Finds all the installation of Java 7, if it exists // Finds all the installation of Java 7, if it exists
// Returns [JavaVersion] // Returns [JavaVersion]
export async function find_filtered_jres(version) { export async function find_filtered_jres(version) {
return await invoke('plugin:jre|jre_find_filtered_jres', { version }) return await invoke('plugin:jre|jre_find_filtered_jres', { version })
} }
// Gets java version from a specific path by trying to run 'java -version' on it. // Gets java version from a specific path by trying to run 'java -version' on it.
// This also validates it, as it returns null if no valid java version is found at the path // This also validates it, as it returns null if no valid java version is found at the path
export async function get_jre(path) { export async function get_jre(path) {
return await invoke('plugin:jre|jre_get_jre', { path }) return await invoke('plugin:jre|jre_get_jre', { path })
} }
// Tests JRE version by running 'java -version' on it. // Tests JRE version by running 'java -version' on it.
// Returns true if the version is valid, and matches given (after extraction) // Returns true if the version is valid, and matches given (after extraction)
export async function test_jre(path, majorVersion, minorVersion) { export async function test_jre(path, majorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion }) return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
} }
// Automatically installs specified java version // Automatically installs specified java version
export async function auto_install_java(javaVersion) { export async function auto_install_java(javaVersion) {
return await invoke('plugin:jre|jre_auto_install_java', { javaVersion }) return await invoke('plugin:jre|jre_auto_install_java', { javaVersion })
} }
// Get max memory in KiB // Get max memory in KiB
export async function get_max_memory() { export async function get_max_memory() {
return await invoke('plugin:jre|jre_get_max_memory') return await invoke('plugin:jre|jre_get_max_memory')
} }

View File

@@ -18,31 +18,35 @@ pub struct Logs {
/// Get all logs that exist for a given profile /// Get all logs that exist for a given profile
/// This is returned as an array of Log objects, sorted by filename (the folder name, when the log was created) /// This is returned as an array of Log objects, sorted by filename (the folder name, when the log was created)
export async function get_logs(profilePath, clearContents) { export async function get_logs(profilePath, clearContents) {
return await invoke('plugin:logs|logs_get_logs', { profilePath, clearContents }) return await invoke('plugin:logs|logs_get_logs', { profilePath, clearContents })
} }
/// Get a profile's log by filename /// Get a profile's log by filename
export async function get_logs_by_filename(profilePath, logType, filename) { export async function get_logs_by_filename(profilePath, logType, filename) {
return await invoke('plugin:logs|logs_get_logs_by_filename', { profilePath, logType, filename }) return await invoke('plugin:logs|logs_get_logs_by_filename', { profilePath, logType, filename })
} }
/// Get a profile's log text only by filename /// Get a profile's log text only by filename
export async function get_output_by_filename(profilePath, logType, filename) { export async function get_output_by_filename(profilePath, logType, filename) {
return await invoke('plugin:logs|logs_get_output_by_filename', { profilePath, logType, filename }) return await invoke('plugin:logs|logs_get_output_by_filename', {
profilePath,
logType,
filename,
})
} }
/// Delete a profile's log by filename /// Delete a profile's log by filename
export async function delete_logs_by_filename(profilePath, logType, filename) { export async function delete_logs_by_filename(profilePath, logType, filename) {
return await invoke('plugin:logs|logs_delete_logs_by_filename', { return await invoke('plugin:logs|logs_delete_logs_by_filename', {
profilePath, profilePath,
logType, logType,
filename, filename,
}) })
} }
/// Delete all logs for a given profile /// Delete all logs for a given profile
export async function delete_logs(profilePath) { export async function delete_logs(profilePath) {
return await invoke('plugin:logs|logs_delete_logs', { profilePath }) return await invoke('plugin:logs|logs_delete_logs', { profilePath })
} }
/// Get the latest log for a given profile and cursor (startpoint to read withi nthe file) /// Get the latest log for a given profile and cursor (startpoint to read withi nthe file)
@@ -57,5 +61,5 @@ export async function delete_logs(profilePath) {
// From latest.log directly // From latest.log directly
export async function get_latest_log_cursor(profilePath, cursor) { export async function get_latest_log_cursor(profilePath, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor }) return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
} }

View File

@@ -3,11 +3,11 @@ import { invoke } from '@tauri-apps/api/core'
/// Gets the game versions from daedalus /// Gets the game versions from daedalus
// Returns a VersionManifest // Returns a VersionManifest
export async function get_game_versions() { export async function get_game_versions() {
return await invoke('plugin:metadata|metadata_get_game_versions') return await invoke('plugin:metadata|metadata_get_game_versions')
} }
// Gets the given loader versions from daedalus // Gets the given loader versions from daedalus
// Returns Manifest // Returns Manifest
export async function get_loader_versions(loader) { export async function get_loader_versions(loader) {
return await invoke('plugin:metadata|metadata_get_loader_versions', { loader }) return await invoke('plugin:metadata|metadata_get_loader_versions', { loader })
} }

View File

@@ -6,13 +6,17 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
export async function login() { export async function login() {
return await invoke('plugin:mr-auth|modrinth_login') return await invoke('plugin:mr-auth|modrinth_login')
} }
export async function logout() { export async function logout() {
return await invoke('plugin:mr-auth|logout') return await invoke('plugin:mr-auth|logout')
} }
export async function get() { export async function get() {
return await invoke('plugin:mr-auth|get') return await invoke('plugin:mr-auth|get')
}
export async function cancelLogin() {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
} }

View File

@@ -4,61 +4,62 @@
* and deserialized into a usable JS object. * and deserialized into a usable JS object.
*/ */
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { create } from './profile' import { create } from './profile'
// Installs pack from a version ID // Installs pack from a version ID
export async function create_profile_and_install( export async function create_profile_and_install(
projectId, projectId,
versionId, versionId,
packTitle, packTitle,
iconUrl, iconUrl,
createInstanceCallback = () => {}, createInstanceCallback = () => {},
) { ) {
const location = { const location = {
type: 'fromVersionId', type: 'fromVersionId',
project_id: projectId, project_id: projectId,
version_id: versionId, version_id: versionId,
title: packTitle, title: packTitle,
icon_url: iconUrl, icon_url: iconUrl,
} }
const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location }) const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
const profile = await create( const profile = await create(
profile_creator.name, profile_creator.name,
profile_creator.gameVersion, profile_creator.gameVersion,
profile_creator.modloader, profile_creator.modloader,
profile_creator.loaderVersion, profile_creator.loaderVersion,
null, null,
true, true,
) )
createInstanceCallback(profile) createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile }) return await invoke('plugin:pack|pack_install', { location, profile })
} }
export async function install_to_existing_profile(projectId, versionId, title, profilePath) { export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
const location = { const location = {
type: 'fromVersionId', type: 'fromVersionId',
project_id: projectId, project_id: projectId,
version_id: versionId, version_id: versionId,
title, title,
} }
return await invoke('plugin:pack|pack_install', { location, profile: profilePath }) return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
} }
// Installs pack from a path // Installs pack from a path
export async function create_profile_and_install_from_file(path) { export async function create_profile_and_install_from_file(path) {
const location = { const location = {
type: 'fromFile', type: 'fromFile',
path: path, path: path,
} }
const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location }) const profile_creator = await invoke('plugin:pack|pack_get_profile_from_pack', { location })
const profile = await create( const profile = await create(
profile_creator.name, profile_creator.name,
profile_creator.gameVersion, profile_creator.gameVersion,
profile_creator.modloader, profile_creator.modloader,
profile_creator.loaderVersion, profile_creator.loaderVersion,
null, null,
true, true,
) )
return await invoke('plugin:pack|pack_install', { location, profile }) return await invoke('plugin:pack|pack_install', { location, profile })
} }

View File

@@ -8,16 +8,16 @@ import { invoke } from '@tauri-apps/api/core'
/// Gets all running process IDs with a given profile path /// Gets all running process IDs with a given profile path
/// Returns [u32] /// Returns [u32]
export async function get_by_profile_path(path) { export async function get_by_profile_path(path) {
return await invoke('plugin:process|process_get_by_profile_path', { path }) return await invoke('plugin:process|process_get_by_profile_path', { path })
} }
/// Gets all running process IDs with a given profile path /// Gets all running process IDs with a given profile path
/// Returns [u32] /// Returns [u32]
export async function get_all() { export async function get_all() {
return await invoke('plugin:process|process_get_all') return await invoke('plugin:process|process_get_all')
} }
/// Kills a process by UUID /// Kills a process by UUID
export async function kill(uuid) { export async function kill(uuid) {
return await invoke('plugin:process|process_kill', { uuid }) return await invoke('plugin:process|process_kill', { uuid })
} }

View File

@@ -4,8 +4,8 @@
* and deserialized into a usable JS object. * and deserialized into a usable JS object.
*/ */
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { install_to_existing_profile } from '@/helpers/pack.js' import { install_to_existing_profile } from '@/helpers/pack.js'
import { handleError } from '@/store/notifications.js'
/// Add instance /// Add instance
/* /*
@@ -19,142 +19,145 @@ import { handleError } from '@/store/notifications.js'
*/ */
export async function create(name, gameVersion, modloader, loaderVersion, iconPath, skipInstall) { export async function create(name, gameVersion, modloader, loaderVersion, iconPath, skipInstall) {
//Trim string name to avoid "Unable to find directory" //Trim string name to avoid "Unable to find directory"
name = name.trim() name = name.trim()
return await invoke('plugin:profile-create|profile_create', { return await invoke('plugin:profile-create|profile_create', {
name, name,
gameVersion, gameVersion,
modloader, modloader,
loaderVersion, loaderVersion,
iconPath, iconPath,
skipInstall, skipInstall,
}) })
} }
// duplicate a profile // duplicate a profile
export async function duplicate(path) { export async function duplicate(path) {
return await invoke('plugin:profile-create|profile_duplicate', { path }) return await invoke('plugin:profile-create|profile_duplicate', { path })
} }
// Remove a profile // Remove a profile
export async function remove(path) { export async function remove(path) {
return await invoke('plugin:profile|profile_remove', { path }) return await invoke('plugin:profile|profile_remove', { path })
} }
// Get a profile by path // Get a profile by path
// Returns a Profile // Returns a Profile
export async function get(path) { export async function get(path) {
return await invoke('plugin:profile|profile_get', { path }) return await invoke('plugin:profile|profile_get', { path })
} }
export async function get_many(paths) { export async function get_many(paths) {
return await invoke('plugin:profile|profile_get_many', { paths }) return await invoke('plugin:profile|profile_get_many', { paths })
} }
// Get a profile's projects // Get a profile's projects
// Returns a map of a path to profile file // Returns a map of a path to profile file
export async function get_projects(path, cacheBehaviour) { export async function get_projects(path, cacheBehaviour) {
return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour }) return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour })
} }
// Get a profile's full fs path // Get a profile's full fs path
// Returns a path // Returns a path
export async function get_full_path(path) { export async function get_full_path(path) {
return await invoke('plugin:profile|profile_get_full_path', { path }) return await invoke('plugin:profile|profile_get_full_path', { path })
} }
// Get's a mod's full fs path // Get's a mod's full fs path
// Returns a path // Returns a path
export async function get_mod_full_path(path, projectPath) { export async function get_mod_full_path(path, projectPath) {
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath }) return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
} }
// Get optimal java version from profile // Get optimal java version from profile
// Returns a java version // Returns a java version
export async function get_optimal_jre_key(path) { export async function get_optimal_jre_key(path) {
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path }) return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
} }
// Get a copy of the profile set // Get a copy of the profile set
// Returns hashmap of path -> Profile // Returns hashmap of path -> Profile
export async function list() { export async function list() {
return await invoke('plugin:profile|profile_list') return await invoke('plugin:profile|profile_list')
} }
export async function check_installed(path, projectId) { export async function check_installed(path, projectId) {
return await invoke('plugin:profile|profile_check_installed', { path, projectId }) return await invoke('plugin:profile|profile_check_installed', { path, projectId })
} }
// Installs/Repairs a profile // Installs/Repairs a profile
export async function install(path, force) { export async function install(path, force) {
return await invoke('plugin:profile|profile_install', { path, force }) return await invoke('plugin:profile|profile_install', { path, force })
} }
// Updates all of a profile's projects // Updates all of a profile's projects
export async function update_all(path) { export async function update_all(path) {
return await invoke('plugin:profile|profile_update_all', { path }) return await invoke('plugin:profile|profile_update_all', { path })
} }
// Updates a specified project // Updates a specified project
export async function update_project(path, projectPath) { export async function update_project(path, projectPath) {
return await invoke('plugin:profile|profile_update_project', { path, projectPath }) return await invoke('plugin:profile|profile_update_project', { path, projectPath })
} }
// Add a project to a profile from a version // Add a project to a profile from a version
// Returns a path to the new project file // Returns a path to the new project file
export async function add_project_from_version(path, versionId) { export async function add_project_from_version(path, versionId) {
return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId }) return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId })
} }
// Add a project to a profile from a path + project_type // Add a project to a profile from a path + project_type
// Returns a path to the new project file // Returns a path to the new project file
export async function add_project_from_path(path, projectPath, projectType) { export async function add_project_from_path(path, projectPath, projectType) {
return await invoke('plugin:profile|profile_add_project_from_path', { return await invoke('plugin:profile|profile_add_project_from_path', {
path, path,
projectPath, projectPath,
projectType, projectType,
}) })
} }
// Toggle disabling a project // Toggle disabling a project
export async function toggle_disable_project(path, projectPath) { export async function toggle_disable_project(path, projectPath) {
return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath }) return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
} }
// Remove a project // Remove a project
export async function remove_project(path, projectPath) { export async function remove_project(path, projectPath) {
return await invoke('plugin:profile|profile_remove_project', { path, projectPath }) return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
} }
// Update a managed Modrinth profile to a specific version // Update a managed Modrinth profile to a specific version
export async function update_managed_modrinth_version(path, versionId) { export async function update_managed_modrinth_version(path, versionId) {
return await invoke('plugin:profile|profile_update_managed_modrinth_version', { path, versionId }) return await invoke('plugin:profile|profile_update_managed_modrinth_version', {
path,
versionId,
})
} }
// Repair a managed Modrinth profile // Repair a managed Modrinth profile
export async function update_repair_modrinth(path) { export async function update_repair_modrinth(path) {
return await invoke('plugin:profile|profile_repair_managed_modrinth', { path }) return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
} }
// Export a profile to .mrpack // Export a profile to .mrpack
/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs') /// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// Version id is optional (ie: 1.1.5) // Version id is optional (ie: 1.1.5)
export async function export_profile_mrpack( export async function export_profile_mrpack(
path, path,
exportLocation, exportLocation,
includedOverrides, includedOverrides,
versionId, versionId,
description, description,
name, name,
) { ) {
return await invoke('plugin:profile|profile_export_mrpack', { return await invoke('plugin:profile|profile_export_mrpack', {
path, path,
exportLocation, exportLocation,
includedOverrides, includedOverrides,
versionId, versionId,
description, description,
name, name,
}) })
} }
// Given a folder path, populate an array of all the subfolders // Given a folder path, populate an array of all the subfolders
@@ -166,39 +169,39 @@ export async function export_profile_mrpack(
// => [mods, resourcepacks] // => [mods, resourcepacks]
// allows selection for 'included_overrides' in export_profile_mrpack // allows selection for 'included_overrides' in export_profile_mrpack
export async function get_pack_export_candidates(profilePath) { export async function get_pack_export_candidates(profilePath) {
return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath }) return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
} }
// Run Minecraft using a pathed profile // Run Minecraft using a pathed profile
// Returns PID of child // Returns PID of child
export async function run(path) { export async function run(path) {
return await invoke('plugin:profile|profile_run', { path }) return await invoke('plugin:profile|profile_run', { path })
} }
export async function kill(path) { export async function kill(path) {
return await invoke('plugin:profile|profile_kill', { path }) return await invoke('plugin:profile|profile_kill', { path })
} }
// Edits a profile // Edits a profile
export async function edit(path, editProfile) { export async function edit(path, editProfile) {
return await invoke('plugin:profile|profile_edit', { path, editProfile }) return await invoke('plugin:profile|profile_edit', { path, editProfile })
} }
// Edits a profile's icon // Edits a profile's icon
export async function edit_icon(path, iconPath) { export async function edit_icon(path, iconPath) {
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath }) return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
} }
export async function finish_install(instance) { export async function finish_install(instance) {
if (instance.install_stage !== 'pack_installed') { if (instance.install_stage !== 'pack_installed') {
let linkedData = instance.linked_data let linkedData = instance.linked_data
await install_to_existing_profile( await install_to_existing_profile(
linkedData.project_id, linkedData.project_id,
linkedData.version_id, linkedData.version_id,
instance.name, instance.name,
instance.path, instance.path,
).catch(handleError) )
} else { } else {
await install(instance.path, false).catch(handleError) await install(instance.path, false)
} }
} }

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