1
0

289 Commits

Author SHA1 Message Date
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
a64c3360d2 refactor: remove unnecessary code lines 2025-07-08 03:08:27 +03:00
a2b2711204 refactor: Improve update.js and RunningAppBar.vue.
Bump to v0.10.302
2025-07-08 01:12:16 +03: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
fba296215d fix for gitea 2025-07-07 18:37:14 +03:00
d7e03fe2be another fix for github actions 2025-07-07 18:20:37 +03:00
ba88244571 fix? 2025-07-07 18:18:57 +03:00
d6d77256fe fix workflow 2025-07-07 18:01:20 +03:00
7449a209fb update workflow 2025-07-07 17:57:14 +03:00
81852859ca update workflow and tauri config 2025-07-07 17:21:03 +03:00
9bd87cf986 Merge commit 'bc90c27e27df60f95a1fdc3572fb0bd5aa4fd102' into feature-clean 2025-07-07 17:14:06 +03:00
Prospector
bc90c27e27 Add ?new to url to give it a new key 2025-07-07 01:18:40 -07:00
Prospector
c1be57773a Update changelog 2025-07-07 01:10:51 -07:00
IMB11
315c68912c fix: use watch for links not mount event (#3929) 2025-07-07 08:01:21 +00:00
Prospector
559d203996 Add a hack to temporarily patch Java 8 not working (#3927) 2025-07-07 00:52:41 -07:00
Prospector
54522518c3 Update changelog + blog post time 2025-07-06 16:37:47 -07:00
Prospector
bacb1561d5 Allow http from asset.localhost and textures.minecraft.net on mac (#3922) 2025-07-06 22:31:55 +00:00
IMB11
b8521f926f feat: skins blogpost (#3904)
* feat: skins blogpost

* fix: clarify changelog note

* Update packages/blog/articles/skins-now-in-modrinth-app.md

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Signed-off-by: IMB11 <hendersoncal117@gmail.com>

* fix: review issues

* fix: lint

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-06 21:43:36 +00:00
IMB11
b29672f4b4 fix: model issues & move to @modrinth/assets (#3911)
* fix: model issues & move to `@modrinth/assets`

* revert: vscode settings change

* fix: remove unused props
2025-07-06 21:42:55 +00:00
Alejandro González
a32fe6a41f ci: revamp app build workflow, introduce a new one for release deployment (#3921)
* feat(ci): clean up app release build workflow, set app versions to match tag's

* feat(ci): rename Theseus build workflow, add new release workflow

* chore(ci): minor tweaks to `theseus-build` workflow

* chore: update workflow reference in comments
2025-07-06 21:41:52 +00:00
IMB11
0e35135093 refactor: cleanup & fix caching issues on /app page. (#3919) 2025-07-06 21:41:21 +00:00
Josiah Glosson
31ecace083 Fix launching older Forge versions (#3920) 2025-07-06 19:09:49 +00:00
Alejandro González
e5b134f8f4 feat(app): add free official Java Edition skin packs as default skins (#3913) 2025-07-06 10:16:11 +00:00
f914ea1c7d fix: Ubuntu CI/CD building 2025-07-06 01:38:26 +03:00
f55da799f1 fix: Skins (beta) incorrect loading and parsing 2025-07-06 01:38:12 +03:00
7e58d7dd35 Merge commit '8faea1663ae0c6d1190a5043054197b6a58019f3' into feature-clean 2025-07-05 23:11:27 +03:00
Ben
139a4863d1 Fix typo for skin name tag settings (#3903)
Signed-off-by: Ben <67504107+bjsho@users.noreply.github.com>
2025-07-05 19:42:20 +00:00
Prospector
8faea1663a liiiiint 2025-07-05 11:37:41 -07:00
Prospector
ece8a07486 Fix bugs with 0.10.0, update changelog 2025-07-05 11:33:28 -07:00
Alejandro González
0030f35d0c fix(theseus): make SQLx migration checksums match the deployed ones on Windows (#3899) 2025-07-05 03:39:52 +00:00
Prospector
1e24225350 Bump app version to 0.10.1 2025-07-04 20:41:27 -07:00
Prospector
e84a178586 add web changelog 2025-07-04 11:46:31 -07:00
Prospector
0a83ed965e Update changelog time 2025-07-04 11:44:39 -07:00
Alejandro González
30035a9a1c fix(app): adjust CSP settings for skin manager to work (#3895)
* fix(app): adjust CSP settings for skin manager to work

* tweak: allow current Tauri scheme in CSP

* tweak: remove references to invalid `sunny.png` texture in skin models

These were causing load errors in production app builds.

* tweak: use proper URL imports for skin models

This fixes importing these models in production builds of the app.

* chore(app-frontend): use more proper import style for glTF assets

* tweak: use proper URL imports for skin models in more places
2025-07-03 23:22:00 +00:00
Alejandro González
512d456c66 fix(app): use the same CSP during tauri dev as tauri build (#3894)
* fix(app): use the same CSP during `tauri dev` as `tauri build`

* chore(app-frontend): make Vite WS CSP policy a bit more strict

* tweak: make Tauri CSP config object readable again

At the cost of some extra code in the Vite config side, but I think it's
worth it.

* chore: fix linter warning in app frontend introduced who knows where else

We need a Git hook to ensure these things aren't pushed only to explode
later on or something.
2025-07-03 21:50:34 +00:00
Prospector
bff26af465 Fix vanilla instances showing as 'Vanilla Shader' 2025-07-03 11:58:27 -07:00
IMB11
f4d0f14cb6 fix: use --landing-raw-bg instead of bg-bg/bg-black (#3891) 2025-07-03 18:51:49 +00:00
Prospector
55916b6bda Fix one version number being wrong 2025-07-03 11:39:49 -07:00
Prospector
a38e1dee1f Remove duplicate changelog 2025-07-03 11:32:57 -07:00
Prospector
ef76a81cd5 Add app 0.10.0 changelog 2025-07-03 11:12:26 -07:00
Ken
9dc5644264 fix: fixed wrong email address (#3884)
* Fix wrong email address

Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>

* Decouple SMTP auth identity from message sender

Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>

* Add new configurations to .env file

Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>

* Update mod.rs

Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>

* Remove unused import

Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>

* Give SMTP_FROM_ADDRESS a default value

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>

* Add the correct host name

Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>

* Fix CI failure

Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>

* Update mod.rs

Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>

---------

Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>
Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Co-authored-by: Emma Alexia <emma@modrinth.com>
2025-07-03 15:41:13 +00:00
Alejandro González
8e35cf6957 fix: format PromotionWrapper.vue according to Prettier style (#3892) 2025-07-03 15:14:54 +00:00
Prospector
ae1c3d6531 Bump app version 2025-07-02 23:55:55 -07:00
Prospector
4964c8d373 Swap MR+ ad fallback to MRS (#3890)
* Replace Modrinth+ ad placeholder promo with Modrinth Servers promo

* Color toggle on web

* Remove plus link click helper
2025-07-02 21:50:33 -07:00
Josiah Glosson
497b2e977e Make tauri-plugin-http a workspace dependency (#3886) 2025-07-02 23:12:10 +00:00
IMB11
f95d0d78f2 feat(app): skins frontend (#3657)
* chore: typo fix and formatting tidyups

* refactor(theseus): extend auth subsystem to fetch complete user profiles

* chore: fix new `prettier` lints

* chore: document differences between similar `Credentials` methods

* chore: remove dead `profile_run_credentials` plugin command

* feat(app): skin selector backend

* enh(app/skin-selector): better DB intension through deferred FKs, further PNG validations

* chore: fix comment typo spotted by Copilot

* fix: less racy auth token refresh logic

This may help with issues reported by users where the access token is
invalid and can't be used to join servers over long periods of time.

* tweak(app-lib): improve consistency of skin field serialization case

* fix(app-lib/minecraft_skins): fix custom skin removal from DB not working

* Begin skins frontend

* Cape preview

* feat: start on SkinPreviewRenderer

* feat: setting for nametag

* feat: hide nametag setting (sql)

* fix: positioning of meshes

* fix: lighting

* fix: allow dragging off-bounds

* fix: better color mapping

* feat: hide nametag setting (impl)

* feat: Start on edit modal + cape button cleanup + renderer fixes

* feat: Finish new skin modal

* feat: finish cape modal

* feat: skin rendering on load

* fix: logic for Skins.vue

* fix: types

* fix: types (for modal + renderer)

* feat: Editing?

* fix: renderer not updating variant

* fix: mojang username not modrinth username

* feat: batched skin rendering - remove vzge references (apart from capes, wip)

* feat: fix sizing on SkinButton and SkinLikeButton, also implement bust positioning

* feat: capes in preview renderer & baked renders

* fix: lint fixes

* refactor: Start on cleanup and polish

* fix: hide error notification when logged out

* revert: .gltf formatting

* chore(app-frontend): fix typos

* fix(app-lib): delay account skin data deletion to next reboot

This gives users an opportunity to not unexpectedly lose skin data in
case they log off on accident.

* fix: login button & provide/inject AccountsCard

* polish: skin buttons

* fix: imports

* polish: use figma values

* polish: tweak underneath shadow

* polish: cursor grab

* polish: remove green bg from CapeLikeTextButton when selected.

* polish: modal tweaks

* polish: grid tweaks + start on upload skin modal

* polish: drag and drop file flow

* polish: button positioning in SkinButton

* fix: lint issues

* polish: deduplicate model+cape stuff and fix layout

* fix: lint issues

* fix: camel case requirement for make-default

* polish: use indexed db to persist skin previews

* fix: lint issues

* polish: add skin icon sizing

* polish: theme fixes

* feat: animation system for skin preview renderer

* feat(app/minecraft_skins): save current custom external skin when equipping skins

* fix: cape button & dynamic nametag sizing

* feat(theseus): add `normalize_skin_texture` Tauri command

This command lets the app frontend opt in to normalizing the texture of
any skin, which may be in either the legacy 64x32 or newer 64x64 format,
to the newer 64x64 format for display purposes.

* chore: Rust build fixes

* feat: start impl of skin normalization on frontend

* feat(theseus): change parameter type of `normalize_skin_texture` Tauri command

* fix: normalization

* fix(theseus): make new `normalize_skin_texture` command usable

* feat: finish normalization impl

* fix: vueuse issue

* fix: use optimistic approach when changing skins/capes.

* fix: nametag cleanup + scroll fix

* fix: edit modal computedAsync not fast enough for skin preview renderer

* feat: classic player model animations

* chore: fix new Clippy lint

* fix(app-lib): actually delete custom skins with no cape overrides

* fix(app-lib): handle repeated addition of the same skin properly

* refactor(app-lib): simplify DB connection logic a little

* fix: various improvements

* feat: slim animations

* fix: z-fighting on models

* fix: shading + lighting improvements

* fix: shadows

* fix: polish

* fix: polish

* fix: accounts card not having the right head

* fix: lint issues

* fix: build issue

* feat: drag and drop func

* fix: temp disable drag and drop in the modal

* Revert "fix: temp disable drag and drop in the modal"

This reverts commit 33500c564e3f85e6c0a2e83dd9700deda892004d.

* fix: drag and drop working

* fix: lint

* fix: better media queries

* feat(app/skins): revert current custom external skin storing on equip

This reverts commit 0155262ddd081c8677654619a09e814088fdd8b0.

* regen pnpm lock

* pnpm fix

* Make default capes a little more clear

* Lint

---------

Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-02 20:32:15 +00:00
Prospector
94a7d13af8 Add creator blog post (#3882)
* Add creator blog post

* Update date
2025-07-02 04:30:02 +00:00
Emma Alexia
3a10e63756 Add blog post: Pride Month 2025 campaign (#3879)
* Add blog post: Pride Month 2025 campaign

* fix lint maybe

* Revert changes to other stuff

* run fix

* use local links

* re-run fix

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-02 01:26:49 +00:00
IMB11
238138d56e fix: app blog issues & consistency (#3880)
* fix: app fetch

* fix: webp default images

* fix: lint issues

* feat: remove default thumbnail from app assets

* fix: webp paths

* fix: use ` instead of "/'

* fix: use AutoLink

* Fix featured article link + changelog page

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-02 01:03:58 +00:00
IMB11
1846c59733 feat: DEV-132 automatic icon generation (#3878)
* feat: automatic icon generation

* fix: lint

* fix: broken icon imports after changes

* fix: deps
2025-07-01 20:54:21 +00:00
Prospector
f1207f0a3a Fix external icons 2025-06-30 19:09:54 -07:00
Prospector
26e964174d Fix duplicate article in blog 2025-06-30 19:06:47 -07:00
Prospector
897418ead3 Fix moderation message article link 2025-06-30 19:05:16 -07:00
IMB11
eef09e1ffe feat: DEV-99 blog migration (#3870)
* feat: blog migration w/ fixes

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

* feat: add changelog button to news page

* fix: lint issues

* refactor: replace nuxt content with `@modrinth/blog`

* feat: shared public folder

* feat: try lazy loading html content

* feat: rss + hide newsletter btn + blog.config.ts

* feat: add new chapter modrinth servers post

* fix: lint issues

* fix: only generate RSS feed if changes detected

* fix: utils dep

* fix: lockfile dep

* feat: GET /email/subscribe + subscription button

* fix: lint issues

* feat: articles.json for app

* Made grid more responsive

* fix: changes

* Make margin slightly smaller in lists

* Fix footer link

* feat: latest news

* Fix responsiveness

* Remove old utm link

* Update changelog

* Lint

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-06-30 18:59:08 -07:00
Emma Alexia
fdb2b1195e Fix some copy codes and avatars not showing up (#3876)
I blame @imb11
2025-07-01 01:02:38 +00:00
IMB11
4b3e036e2a fix: cmp-info route. (#3875) 2025-06-30 22:51:40 +00:00
Josiah Glosson
3233e7fc54 Fix old Minecraft versions not having playtime resolved for servers (#3871)
* Fix old Minecraft versions not having playtime resolved for servers

* Revert and clean up get_server_worlds_in_profile a bit

* Add a semaphore to resolve_server_address in general to apply to all DNS queries

* Remove unused tokio-stream dependency from theseus
2025-06-30 22:36:38 +00:00
IMB11
dd98a1316a fix: Unsatisfactory rounding of download sums (#3872)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-06-30 22:08:05 +00:00
IMB11
e5030a8fbe feat: mrpack upload progress in modal (#3867)
* feat: mrpack upload progress in modal

* fix: remove min progress
2025-06-30 21:52:03 +00:00
IMB11
f549560e47 fix: broken files status card on server panel (#3873) 2025-06-30 21:47:30 +00:00
Jai Agrawal
33d26238ce Fix revenue route incorrect filter (and commit bank transaction) (#3874)
* Fix revenue route incorrect filtering

* Actually commit transaction
2025-06-30 14:45:23 -07:00
Alejandro González
bcec478a64 fix(app-frontend): clamp current page in searches to the max possible (#3869) 2025-06-30 14:12:24 +00:00
Jai Agrawal
8971d39683 Add bank balances to DB (#3860) 2025-06-29 14:46:54 +00:00
Tiger
1c1631f131 fix: tooltip text color (#3866) 2025-06-29 14:46:15 +00:00
Emma Alexia
14b1ff79e0 Fix empty collections being shown on user pages (#3864)
Originally changed in #3408 but didn't address the full issue.
2025-06-29 13:37:41 +00:00
Emma Alexia
479aaf503b Force RAM to be listed by bytes instead of percent when in dev mode (#3853)
* Force RAM to be listed by bytes instead of percent when in dev mode

Makes things easier for support.

* fix lint
2025-06-29 08:47:03 +00:00
Alejandro González
240cccf8a1 Tweak Modrinth+ page according to latest changes (#3863)
* tweak(pages/plus): update lack of ads perk desc to match latest changes

* tweak(pages/plus): more perks coming soon -> soon™

At this point it feels a bit fake for reasonable definitions of "soon"
to keep stating that more perks are coming "soon", even though it's not
something that has not been discarded altogether.

However, I think everyone can agree on a more playful and realistic
"soon™" deadline, because everyone likes memes and can relate to things
taking longer to come to fruition than planned :)
2025-06-29 00:14:59 +00:00
Jai Agrawal
2599dc2672 Disable ads for logged in users (web) (#3858)
* Disable ads for logged in users (web)

* Fix lint
2025-06-28 22:00:40 +00:00
Emma Alexia
e2668f20b7 Give free upgrades when billing period is near its end (#3851)
Some users elect to try to perform their upgrade immediately before their subscription renews. However, we throw an error whenever the proration charge is under 30 cents because we lose more money on fees than we gain by charging the customer. This PR changes charges so that the user's server will simply be provided a free upgrade instead of requiring them to wait until after their next renewal.
2025-06-28 21:57:38 +00:00
Jai Agrawal
cf767c7ef2 Fix platform revenue route (#3857) 2025-06-28 21:55:01 +00:00
IMB11
14a7787e3d fix: info panel (#3859) 2025-06-28 21:54:56 +00:00
Josiah Glosson
db963eb5de Set JAVA_HOME to JAVA_HOME_11_X64 on Windows for theseus-release (#3848)
* Set JAVA_HOME to JAVA_HOME_11_X64 on Windows for theseus-release

* Add quotes around $env:JAVA_HOME_11_X64

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Josiah Glosson <soujournme@gmail.com>

---------

Signed-off-by: Josiah Glosson <soujournme@gmail.com>
Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
2025-06-27 15:44:36 +00:00
Alejandro González
a1812cd954 fix(labrinth): set a proper replica identity during the environments migration (#3852)
This should fix a migration error that happened on our production
environment.
2025-06-27 12:16:02 +00:00
Josiah Glosson
5ed9d1749a Rust dependency updates (#3849)
* Update async-compression 0.4.24 -> 0.4.25

* Update reqwest 0.12.19 -> 0.12.20

* Update rust_decimal 1.37.1 -> 1.37.2

* Update sentry 0.38.1 -> 0.41.0

* Update sentry-actix 0.38.1 -> 0.41.0

* Update serde_with 3.12.0 -> 3.13.0

* Update tauri 2.5.1 -> 2.6.1 and all Tauri dependencies

* Update zip 4.0.0 -> 4.2.0

* Update Rust 1.87.0 -> 1.88.0
2025-06-27 09:54:51 +00:00
Emma Alexia
17ca209862 Always show developer mode attributes on admin billing page (#3850)
* Always show developer mode attributes on admin billing page

* Unprovision servers by default when refunding
2025-06-27 01:04:52 +00:00
Alejandro González
03192c1dfd fix(app-lib): do not softlock tauri dev when a Gradle build is invoked (#3847)
An unforeseen consequence of PR #3833 landing was that `tauri dev`
stopped working reliably, getting softlocked when the `app-lib` crate
build script actually needed to build Java scripts: Gradle always
modifies a few files under the `.gradle` directory when run, which get
picked up by Tauri as source code changes that should trigger a rebuild,
but such rebuild triggers Gradle to run and modify those files again ad
infinitum.

This change fixes that by adding such a directory to a documented Tauri
exclusion file, restoring such functionality back.
2025-06-26 21:46:46 +00:00
Emma Alexia
6f03fae233 Clear owner's project cache after deleting organization (#3794)
* Clear owner's project cache after deleting organization

Fixes an issue where people would think their projects were deleted along with their organization, when this isn't actually the case.

* address PR review

* Update apps/labrinth/src/routes/v3/organizations.rs

Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>

* Fix lint

* actually fix lint

---------

Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>
2025-06-26 19:30:58 +00:00
Emma Alexia
22fc0c994d Remove new-projects channel emojis (#3843)
* Remove new-projects channel emojis

With new loaders, this functionality has become unwieldy. We don't have enough emoji slots in the server for the number of emojis we'd need for the loaders. It is easiest to simply format them in the same way knossos does.

Note: I forgor how the borrow checker works, this compiles but I'm sure it's gore to anyone who actually knows the difference between a string slice and a String, I come from Javaland though so pls forgive

* Rename func accordingly
2025-06-26 18:54:22 +00:00
Josiah Glosson
a1ccbc5757 Update Java to 11 with --release 8 (#3846) 2025-06-26 18:38:58 +00:00
Jai A
053cf10198 Update ads.txt again
(cherry picked from commit 67304fab25ed37ed3e39766dc9378ae3a760cb06)
2025-06-26 11:08:44 -07:00
Jai A
257efd8ad7 Update ads.txt
(cherry picked from commit 01037c184efad97e4b9f1a11c3e7e1eb21d44510)
2025-06-26 11:08:44 -07:00
Alejandro González
b75cfc063b Sign Windows Theseus binaries with DigiCert KeyLocker's cloud HSM (#3838)
* feat(ci): sign Windows Theseus bins with DigiCert KeyLocker cloud HSM

* perf(ci): speed up Jsign installation

* fix(ci): use absolute path to DigiCert client certificate

This should avoid errors related to Jsign not being able to find it
we've seen on CI.

* fix(ci): trim strange characters out from DigiCert credentials

* ci: another attempt at fixing Jsign errors

* chore: add comment mentioning why `jsign` choco deps are ignored

* tweak: move KeyLocker signing config to CI release Tauri config file

This prevents casual local builds from attempting to use a signing
command they really can't use, improving developer experience.

* tweak(ci/windows): do not waste time and signatures with MSIs

We aren't distributing these anyway. This should reduce the signing
operations required for building the app from 5 (one for the binary,
another for the MSI installer, two for WiX extension DLLs and one for
the NSIS installer) to 2.

* feat(ci): make Windows code signing toggleable, do not sign non-final builds

* chore(ci): tweak `sign-windows-binaries` input wording

* fix(ci): deal with usual Powershell syntax shenanigans

* fix(ci): work around more Powershell syntax shenanigans

Who thought it'd be a good idea to make a comma a synonymous of a space
for separating command line arguments? Why have to characters for the
same thing?

* perf(ci): do not run app build workflow on Labrinth changes

Labrinth is not related to the app at all, so this is just a waste of CI
minutes.

* ci(theseus): enable Windows code signing by default for manual triggers

These are expected to be not that common, so defaulting to what causes
the least human errors when it comes to publishing a release makes most
sense.
2025-06-26 17:43:20 +00:00
Prospector
2d8420131d Update changelog 2025-06-26 10:45:48 -07:00
Prospector
c793b68aed Add quick server button, dynamic price preview for custom server modal (#3815)
* Add quick server creation button, and dynamic pricing to custom server selection

* Remove test in compatibility card

* Lint + remove duplicate file

* Adjust z-index of popup

* $6 -> $5

* Dismiss prompt if the button is clicked

* Make "Create a server" disabled for now

* Use existing loaders type
2025-06-26 15:38:42 +00:00
Josiah Glosson
47af459f24 Migrate Java code to Gradle (#3833)
* Create get_resource_file macro to get an embedded resource

If the tauri feature is enabled, the resource will be loaded from Tauri resources.
If the tauri feature is disabled, the resource will be extracted to a temp directory.

* Wrap process execution to inject system properties through stdin

* Pass the time values as ISO 8601 datetimes

* Remove entirely internal modrinth.process.uuid

* Redo Java version checking somewhat and fix a few bugs with it

* Fix game launch with early access versions of Java

* Format Java code

* Use Gradle to build Java code

* Make Gradle build reproducible

* Fix constant rebuilds

* Get rid of unnecessary rebuilds

* Fix modrinth.profile.modified being the same as modrinth.profile.created

* Make javac use all lints and treat them as errors

* Force Gradle color output

* Add Java formatting config

* Make gradlew executable

* Revert to manually extracting class files

* Switch to using update resource macro

* fix: make `app-lib` build again

---------

Co-authored-by: Alejandro González <me@alegon.dev>
2025-06-26 14:56:35 +00:00
Josiah Glosson
f10e0f2bf1 Pass system properties into Minecraft (+ some launch code cleanup) (#3822)
* Create get_resource_file macro to get an embedded resource

If the tauri feature is enabled, the resource will be loaded from Tauri resources.
If the tauri feature is disabled, the resource will be extracted to a temp directory.

* Wrap process execution to inject system properties through stdin

* Pass the time values as ISO 8601 datetimes

* Remove entirely internal modrinth.process.uuid

* Redo Java version checking somewhat and fix a few bugs with it

* Fix game launch with early access versions of Java

* Format Java code

* Fix modrinth.profile.modified being the same as modrinth.profile.created

* Revert to manually extracting class files
2025-06-26 13:23:14 +00:00
Alejandro González
569d60cb57 tweak(labrinth): return proper error message for invalid password change flows (#3839) 2025-06-25 16:51:08 +00:00
Ken
74d36a6a2d Update config.toml (#3832)
Signed-off-by: Ken <131881470+Keniis0712@users.noreply.github.com>
2025-06-23 22:40:16 +00:00
Prospector
ced073d26c Add colors for new loaders, reduce utility redundancy (#3820)
* Add colors to some newer loaders

* Make loader formatting consistent everywhere, remove redundant utilities
2025-06-21 14:35:42 +00:00
Josiah Glosson
cc34e69524 Initial shared instances backend (#3800)
* Create base shared instance migration and initial routes

* Fix build

* Add version uploads

* Add permissions field for shared instance users

* Actually use permissions field

* Add "public" flag to shared instances that allow GETing them without authorization

* Add the ability to get and list shared instance versions

* Add the ability to delete shared instance versions

* Fix build after merge

* Secured file hosting (#3784)

* Remove Backblaze-specific file-hosting backend

* Added S3_USES_PATH_STYLE_BUCKETS

* Remove unused file_id parameter from delete_file_version

* Add support for separate public and private buckets in labrinth::file_hosting

* Rename delete_file_version to delete_file

* Add (untested) get_url_for_private_file

* Remove url field from shared instance routes

* Remove url field from shared instance routes

* Use private bucket for shared instance versions

* Make S3 environment variables fully separate between public and private buckets

* Change file host expiry for shared instances to 180 seconds

* Fix lint

* Merge shared instance migrations into a single migration

* Replace shared instance owners with Ghost instead of deleting the instance
2025-06-19 19:46:12 +00:00
IMB11
d4864deac5 fix: intercom bubble colliding with notifications (#3810) 2025-06-19 15:18:10 +00:00
IMB11
125207880d fix: state update race conditons (#3812) 2025-06-19 15:18:00 +00:00
IMB11
a8f17f40f5 fix: unclear file upload errors temp fix (#3811) 2025-06-19 15:17:50 +00:00
84bbb0d68f possible workflow fix for Gitea 2025-06-19 04:02:51 +03:00
0a9dc5edfe update README 2025-06-19 03:56:16 +03:00
e5410e9c8e fixed: unexpected error in crate visibility from minecraft_auth.rs 2025-06-19 03:46:54 +03:00
21f2c0e7aa Merge commit 'dbde3c4669af10dd577590ed6980e5bd4552d13c' into feature-clean 2025-06-19 03:45:56 +03:00
7b234c7f55 possible ubuntu build fix 2025-06-19 03:30:20 +03:00
Prospector
dbde3c4669 Remove duplicate components in web frontend Avatar, Badge, CopyCode, and Pagination (#3741) 2025-06-19 00:07:15 +00:00
Josiah Glosson
ba4fecb0cb Fix direct lint of packages/app-lib (#3808)
* Fix theseus lint by adding "unstable" feature to Tauri

* Remove "unstable" feature from tauri in apps/app
2025-06-18 20:49:23 +00:00
Alejandro González
ef04dcc37b feat(labrinth): rework v3 side types to a single environment field (#3701)
* feat(labrinth): rework v3 side types to a single `environment` field

This field is meant to be able to represent the existing v2 side type
information and beyond, in a way that may also be slightly easier to
comprehend.

* chore(labrinth/migrations): use proper val for `HAVING` clause

* feat(labrinth): add `side_types_migration_review_status` field to projects
2025-06-16 22:44:57 +00:00
Prospector
65126b3a23 Update changelog again 2025-06-16 11:00:15 -07:00
Prospector
58495e6276 Update changelog 2025-06-16 10:59:01 -07:00
IMB11
706976439d fix: error handling improvements (#3797)
* fix: error handling improvements

* refactor: error info cards

* refactor: PyroError -> ModrinthError

* fix: lint

* fix: idiot

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-06-16 17:34:18 +00:00
jade
0a9ffd3dc8 fix(moderation): Insufficient gallery images moderation message indentation (#3799) 2025-06-16 16:48:35 +00:00
Alejandro González
fb30c0ba2b feat(labrinth): allow protected resource and data packs to pass validation (#3792)
* fix(labrinth): return version artifact size exceeded error eagerly

Now we don't wait until the result memory buffer has grown to a size
greater than the maximum allowed, and instead we return such an error
before the buffer is grown with the current chunk, which should reduce
memory usage.

* fix(labrinth): proper supported game versions range for datapacks

* feat(labrinth): allow protected resource and data packs to pass validation
2025-06-16 16:30:01 +00:00
Alejandro González
97e4d8e132 fix(labrinth): ensure versions get removed from search indexes before ending route execution (#3789)
* fix(labrinth): ensure versions get removed from search indexes before ending route execution

* chore: run `sqlx prepare`

* chore(labrinth): simplify `remove_documents` a little

* chore: tweak new comment
2025-06-16 15:48:04 +00:00
Emma Alexia
5bdff3929b Allow failed subscriptions to be cancelled (#3795)
When a payment for a subscription fails, we continue to try to re-attempt retrieving payment for 30 days.

Sometimes making it fail is an intentional choice on the user's part (e.g. Privacy.com card) or other times the user just doesn't want their subscription anymore after it fails.

This PR allows users with a failed payment to simply cancel instead of waiting for the 30-day timer to set in.
2025-06-16 05:41:07 +00:00
Prospector
a08562bfe2 Update changelog 2025-06-15 16:19:20 -07:00
Prospector
2b4319ea55 Servers hotfixes (#3793)
* servers: Fix installing modpacks from search

* remove console.log

* Fix subdomain setting
2025-06-15 16:17:38 -07:00
Prospector
c32405720d Fix changelog 2025-06-15 14:39:19 -07:00
Alejandro González
b9ba3cd3e8 fix(labrinth): use a proper CDN_URL for local deployments (#3791) 2025-06-15 15:19:56 +00:00
Prospector
31381c860b Update changelog 2025-06-14 10:47:19 -07:00
IMB11
e410a07cac fix: usePyroServers -> useModrinthServers (#3788) 2025-06-14 11:27:38 +00:00
Prospector
9f93cd8705 Update changelog 2025-06-13 19:15:44 -07:00
Prospector
dd391be095 Fix lint 2025-06-13 18:59:28 -07:00
Alejandro González
f84f8c1c2b chore(clippy): enable and fix many stricter lints (#3783)
* chore(clippy): enable and fix many stricter lints

These ensure that the codebase uses more idiomatic, performant, and
concise language constructions.

* chore: make non-Clippy compiler warnings also deny by default
2025-06-14 00:10:12 +00:00
Alejandro González
301967d204 refactor: inherit Clippy lint config and Rust edition from workspace (#3782)
* refactor: inherit Clippy lint config and Rust edition from workspace

This also ensures developers running `clippy lint` locally get the same
lints as during CI, especially when the Rust toolchain version is fixed
through a `rust-toolchain.toml` file.

* chore(clippy.toml): bump MSRV to 1.87
2025-06-13 23:16:48 +00:00
Alejandro González
c9b98a6154 Small CI flakiness fix and performance tweak (#3780)
* perf(ci): use Turbo to schedule both `lint` and `test` tasks at once

* fix(ci): wait until service containers are initialized for tests

This is achieved by adding a health check to the containers, and
instructing the CI workflow to wait until the containers are healthy.
Not doing this wait risks spurious CI failures due to DB migrations
being applied before the DB even starts.

* chore(turbo): use locally installed schema in new Turbo override file

On the latest versions of Turbo, this ensures that the used schema is
always in sync with what's available in the installed Turbo version,
which is something that has already caused confusion to me before.
2025-06-13 21:34:40 +00:00
Alejandro González
ab8e474339 Update Rust and Turbo versions (#3781)
* chore: bump Rust version from 1.86 to 1.87

* chore: update Turbo

* chore(.cargo/config.toml): minor comment tweak
2025-06-13 21:03:35 +00:00
Alejandro González
8a26011e76 fix(app): make per-instance launch hooks clearable (#3757)
* fix(app): make per-instance launch hooks clearable

* chore(apps/app-frontend): fix Prettier lints
2025-06-13 20:53:47 +00:00
Alejandro González
d4de1dc9a1 fix(app): make instances with non-UTF8 text file encodings launcheable and importable (#3721)
Previous to these changes, the app always assumed that Minecraft and
other launchers always use UTF-8, which is not necessarily always true.
2025-06-13 20:52:57 +00:00
Alejandro González
4e3bd4e282 enh(ci): optimize Turbo CI check workflow, track Rust and Node toolchain versions in well-known files (#3776)
* enh(ci): optimize Turbo CI check workflow, track Rust and Node toolchain versions in well-known files

* fix(ci): build `sqlx-cli` with `rustls` to fix Postgres TLS failures
2025-06-12 16:47:28 -07:00
IThundxr
d24528f6a6 frontend: Improve file too large error (#3774)
* Improve file too large error

Signed-off-by: IThundxr <me@ithundxr.dev>

* MB -> MiB

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

---------

Signed-off-by: IThundxr <me@ithundxr.dev>
Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-06-12 02:05:30 +00:00
IMB11
1b1d41605b refactor: Huge pyro servers composable cleanup (#3745)
* refactor: start refactor of pyro servers module-based class

* refactor: finish modules

* refactor: start on type checking + matching api

* refactor: finish pyro servers composable refactor

* refactor: pyro -> modrinth

* fix: import not refactored

* fix: broken power action enums

* fix: remove pyro mentions

* fix: lint

* refactor: fix option pages

* fix: error renames

* remove empty pyro-servers.ts file

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
2025-06-11 22:32:39 +00:00
Magnus Jensen
6955731def fix: undefined instance path by using emitted event instead when opening world folder (#3746)
* fix: undefined instance path by using emitted event instead

* fix: linting
2025-06-11 22:25:20 +00:00
worldwidepixel
4386891716 feat(frontend): Organisations are now sorted alphabetically in dashboard and on user pages (#3755)
* feat: Organisations are now sorted alphabetically in dashboard and on user pages

* Use computed ref

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-06-11 22:24:04 +00:00
Prospector
6741aba880 Add segmentation to reports list to fix it (#3772) 2025-06-11 22:22:47 +00:00
Alejandro González
ee8ee7af82 feat(labrinth): ignore email case differences in password recovery flow (#3771)
* feat(labrinth): ignore email case differences in password recovery flow

* chore(labrinth): run `sqlx prepare`
2025-06-11 21:59:21 +00:00
IMB11
a2e323c9ee fix: MOD-292 repair button showing during installation (#3734)
* fix: MOD-292 repair button showing during installation

* fix: lint

* Update apps/app-frontend/src/pages/instance/Index.vue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: IMB11 <hendersoncal117@gmail.com>

* fix: lint issues

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-11 21:54:08 +00:00
IMB11
f8fb23e05f fix: hydration issues caused by duplicate components on servers panel (#3753)
* fix: server stats icons

* fix: fix chart jumping

* refactor: iconComponent -> icon

* fix: panel hydration issues

* fix: apply requested changes
2025-06-11 21:30:24 +00:00
Alejandro González
a3839461cf perf(labrinth/random_projects_get): speed up through spatial queries according to profiling results (#3762) 2025-06-09 22:19:58 +00:00
Prospector
858c7e393f Add handling for new loaders, fix max height being applied when scrolling is disabled from #2898 (#3761) 2025-06-09 21:59:59 +00:00
Josiah Glosson
0278241006 Update a bunch of dependencies (#3766) 2025-06-09 19:48:03 +00:00
Josiah Glosson
3afb682fc6 Make get_user_from_headers and check_is_moderator_from_headers take in a bitflag of Scopes rather than a slice of Scopes (#3765) 2025-06-09 19:29:32 +00:00
Emma Alexia
06f1df1995 Fix random_projects route not returning the requested number of projects (#3758)
* Fix random_projects route not returning the requested number of projects

* fix(labrinth): further improve random project route SQL query

* chore: fix typo in comment

* tweak(labrinth): more apparent and fast randomness for `random_projects_get`

* tweak(labrinth): even better random projects query

* chore: address formatting review

---------

Co-authored-by: Alejandro González <me@alegon.dev>
2025-06-08 23:49:39 +00:00
Magnus Jensen
3489771d2e fix: reset reset-icon state value correctly in edit world modal (#3748) 2025-06-05 22:03:52 +00:00
Erb3
448ae5a2b7 fix(frontend): remove fixed height from ManySelect (#2898)
* fix(frontend): remove fixed height from ManySelect

Frontend development is not my passion, there might be a better fix.

I've tested my changes in all places that I found using the chganed components (ManySelect, ScrollablePanel):

- Changelog filters
- Version filters
- Download dialog
- Search filters

Fixes #2334

* Revert incorrect merge

* fix merge conflict
2025-06-04 22:07:32 +00:00
Prospector
72340790e5 Show up to 15 projects in chart tooltips (#3739) 2025-06-04 20:19:06 +00:00
Prospector
c9423fe478 Fix server intro not ending when installing loader 2025-06-03 20:28:23 -07:00
Prospector
02a850ae63 lint 2025-06-03 18:15:23 -07:00
Prospector
ede6d0c3cc Change region order 2025-06-03 16:23:37 -07:00
Prospector
7685989a8c Update regions FAQ 2025-06-03 16:21:35 -07:00
Prospector
4e8ebb5e5c Servers fixes 2025-06-03 16:16:56 -07:00
Prospector
3f77ab19ed Fix skeleton not showing on purchase 2025-06-03 13:18:05 -07:00
Prospector
d3d0c8c523 Fix skeleton sticking around on back, add new server indicator 2025-06-03 12:30:21 -07:00
Prospector
4e093131f3 Install issues 2025-06-03 11:25:31 -07:00
Prospector
6ca8a4e5fd Fix QA issues 2025-06-03 11:09:22 -07:00
Prospector
63b15ded60 Send region 2025-06-03 10:57:37 -07:00
Prospector
85e65aeffe intl 2025-06-03 10:43:05 -07:00
Prospector
ad44398492 en dash 2025-06-03 10:40:35 -07:00
Prospector
a4ba41bf15 Lint, make save button not clickable when quarterly is selected 2025-06-03 10:37:29 -07:00
Prospector
4441be5380 Fixes to billing 2025-06-03 09:22:54 -07:00
Prospector
c0accb42fa Servers new purchase flow (#3719)
* New purchase flow for servers, region selector, etc.

* Lint

* Lint

* Fix expanding total
2025-06-03 09:20:53 -07:00
François-Xavier Talbot
7223c2b197 Include region in user subscription metadata (#3733) 2025-06-02 05:13:06 +00:00
Emma Alexia
7b535a1c2a Enable charity payouts through Tremendous (#3732) 2025-06-01 23:53:45 +00:00
Alejandro González
0aa76567a6 docs(issue template): do not skip closed issues in link to existing issues (#3728)
When an issue has been already handled by our part, and thus gets
closed, but affects many users and the fix takes a while to be rolled
out, it usually happens that those who notice the matter later on don't
notice previous reports and create duplicate issues.

Let's try to improve a little bit on that by not filtering out closed
issues in the links for checking whether the same issue was already
reported before. This should make it more obvious to users who follow
the link whether an issue for their problem already exists.
2025-06-01 14:51:00 +00:00
Alejandro González
6fa1369c49 fix(labrinth): tentative billing period update fix (#3722)
* fix(labrinth/billing): add Spain and Singapore to the list of countries for currency inferences

This should fix payments in those countries not going through with their
local currencies for products that do not have USD-only pricing.

* fix(labrinth/billing): tentative fix for subscription periods not updating
2025-05-31 09:00:08 +00:00
Emma Alexia
b66d99c59c Improve error when Modrinth's PayPal account is out of funds (#3718)
* Improve error when Modrinth's PayPal account is out of funds

* improve msg
2025-05-30 16:28:00 +00:00
Alejandro González
a9cfc37aac Some small Labrinth refactors and fixes (#3698)
* chore(labrinth): fix typos, simplify out `remove_duplicates` func

* fix(labrinth): implement `capitalize_first` so that it can't panic on wide chars

* chore(labrinth): refactor out unneeded clone highlighted by nightly Clippy lints

* chore(labrinth): simplify `capitalize_first` implementation

* fix(labrinth): preserve ordering when deduplicating project field values

This addresses an unintended behavior change on
157647faf2778c74096e624aeef9cdb79539489c.

* fix(labrinth/tests): make `index_swaps` test run successfully

I wonder why we don't run these more often...

* refactor: rename `.env.example` files to `.env.local`, make local envs more consistent between frontend and backend

* chore(labrinth/.env.local): proper email verif. and password reset paths
2025-05-29 20:51:30 +00:00
Alejandro González
be37f077d3 feat(labrinth): quarterly billing support (#3714) 2025-05-28 22:18:24 +00:00
François-Xavier Talbot
f52d020a3c Support specifying a region when creating servers (#3709)
* Support specifying a region when creating servers

* Remove hardcoded default server region
2025-05-26 21:21:52 +00:00
d7302f01d0 Merge branch 'feature-clean' of https://git.astralium.su/didirus/AstralRinth into feature-clean 2025-05-26 18:02:46 +03:00
0e2447f41b update: bump to v0.9.501 and macos build fix 2025-05-26 18:00:42 +03:00
3c03cd6091 Merge commit '74cf3f076eff43755bb4bef62f1c1bb3fc0e6c2a' into feature-clean 2025-05-26 17:59:09 +03:00
Emma Alexia
74cf3f076e Automatically fail payments that are older than 30 days (#3697) 2025-05-25 19:36:19 +00:00
Emma Alexia
84adf79564 Fix resubscription of servers with failed payments (#3696)
* Fix resubscription of servers with failed payments

Resolves MOD-55

* run fix
2025-05-25 19:36:14 +00:00
Emma Alexia
dc0d923cee Automatically cancel servers with failed payments older than 30d (#3695) 2025-05-25 19:36:11 +00:00
Emma Alexia
2ffd7476aa Get rid of a bit of dead code around server suspensions (#3693)
Might fix some issues with people getting errors saying their servers are suspended when it's actually upgrading
2025-05-25 19:36:07 +00:00
jade
034fd06284 fix: make insufficient gallery images checklist item always show (#3692)
* fix(moderation): make insufficient gallery images checklist item always show

* fix(moderation): Make gallery ordering more consistent
2025-05-25 19:36:03 +00:00
Emma Alexia
49aac6bdca Fix {{ email_description }} in Modrinth emails (#3703) 2025-05-25 19:34:46 +00:00
Josiah Glosson
4e4a7be7ef Commonize and distinguish a lot of struct names in labrinth::database::models (#3691) 2025-05-24 09:38:43 +00:00
Josiah Glosson
9c1bdf16e4 Update Redis dependencies (#3682) 2025-05-22 15:01:47 +00:00
Josiah Glosson
9e527ff141 Labrinth ID cleanup (#3681)
* Put all ID types in the labrinth::models::ids, and reduce code duplication with them

* Rewrite labrinth::database::models::ids and rename most DB interface ID structs to be prefixed with DB

* Run sqlx prepare

---------

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
2025-05-22 08:34:36 +00:00
Alejandro González
c6022ad977 fix(labrinth): prevent division by zero panic on analytics routes (#3668) 2025-05-19 19:14:51 +00:00
Alejandro González
ea9a3539eb Minor Labrinth documentation and missing Sendy configuration handling tweaks (#3667)
* docs(contributing/labrinth): mention SMTP vars, add helpful Redis note

* chore(labrinth): short-circuit Sendy usage successfully when not setup

* chore: get rid of deprecated `docker-compose` version parameter
2025-05-18 11:36:05 +00:00
Alejandro González
e4f0dddf82 PR 3655 regression fixes (#3664)
* chore: undo unintended updater `zip` feature drop, tweak comment

* fix: correct unintended regression on version and project validation

This was caused by a mistake when coalescing mostly copied and pasted
`RE_URL_SAFE` regexes into one.
2025-05-16 21:44:03 +00:00
jade
be425cff6f feat(moderation): Add more checklist messages (#3662) 2025-05-16 18:58:24 +00:00
Calum H.
e225bc9f66 feat: standardized page banners (#3610)
* feat: standardized site banners

* fix: lint issues

* fix: deduplicate SCSS with variant map

* feat: color shades + reduced scss

* feat: fix theming

* chore: Remove shades-generator.ts

* fix: lint issues

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-05-16 10:12:34 +00:00
Alejandro González
f19643095e Inherit dependencies from workspace manifest, and optimize some out (#3655)
* chore: inherit dependencies from workspace, optimize some deps out

* Update bitflags from 2.9.0 to 2.9.1

* Fix temp directory leak in check_java_at_filepath

* Fix build

* Fix lint

* chore(app-lib): refactor overkill `futures` executor usage to Tokio MPSC

* chore: fix Clippy lint

* tweak: optimize out dependency on OpenSSL source build

Contrary to what I expected before, this was caused due to the Tauri
updater plugin using a different TLS stack than everything else.

* chore(labrinth): drop now unused dependency

* Update zip because 2.6.1 got yanked

* Downgrade weezl to 0.1.8

* Mention that p256 is also a blocker for rand 0.9

* chore: sidestep GitHub review requirements

* chore: sidestep GitHub review requirements (2)

* chore: sidestep GitHub review requirements (3)

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
2025-05-15 20:47:29 +00:00
lumiscosity
37cc81a36d chore: optimize PNGs (#3647) 2025-05-15 11:49:01 +00:00
Emma Alexia
863bf62f8d Fix updated field including deleted versions (#3643)
* Fix `updated` field including deleted versions

Four years ago, I created issue modrinth/labrinth#200. Today, while it adorns a different name (modrinth/code#2766), the issue remains the same. In celebration of Modrinth's oldest bug report, here is a fix.

Instead of having a separate `updated` field, it simply pulls the publish date of the most recent version. This should also allow the `updated` column on the `mods` table to be dropped at a later date, but I would rather get confirmation that it works before we go ahead with that.

Fixes #2766

* Update apps/labrinth/src/database/models/project_item.rs

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>

---------

Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>
Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
2025-05-12 18:19:30 +00:00
Emma Alexia
9a6390bb4d Fix organization projects route properly (#3633)
* Revert "fix: capitalization of ID org route breaks projects list (#3621)"

This reverts commit e4adbb9469.

* Fix organization projects route properly

Reverted #3621 because it caused more bugs to be created, in the form of organizations with capital letters not showing any projects

* Update apps/labrinth/src/routes/v3/organizations.rs

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>

* fix copy-paste error

---------

Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>
Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
2025-05-09 23:28:52 +00:00
Josiah Glosson
62de07e4e6 Version updates (#3626)
* Update some Labrinth dependencies

* Update some Labrinth dependencies

* Update some Labrinth dependencies

* Update zip in Labrinth

* Update itertools in Labrinth

* Update validator in labrinth

* Update thiserror in labrinth

* Update rust_decimal, redis, and deadpool-redis in labrinth

* Update totp-rs and spdx in labrinth

* Update maxminddb and tar in labrinth

* Update sentry and sentry-actix in labrinth

* Update image in labrinth

* Update lettre in labrinth

* Update derive-new and rust_iso3166 in labrinth

* Update async-stripe and json-patch in labrinth

* Update clap and iana-time-zone in labrinth

* Update labrinth to Rust 2024

* Cargo fmt

* Just do a full cargo update

* Update daedelus to Rust 2024

* Update daedelus_client to Rust 2024

* Set the formatting edition to 2024

* Fix formatting

IntelliJ messed up my formatting
2025-05-09 12:27:55 +00:00
Calum H.
6e46317a37 feat(frontend): refactor and modernize welcome page (#3614)
* feat(frontend): refactor and modernize welcome page - also fixes navbar issue.

Closes: #1533

* fix(frontend): lint issues & use standard variables instead of the constants from error.vue

* fix(frontend): remove creator count as it's not a count of all users

* fix(frontend): lang reshuffle

* feat: rinthbot

* fix: lint issues

* fix: sizing of bot on mobile & scss cleanup for error.vue

* fix: lint issues

* fix: ui lint
2025-05-08 16:14:25 +00:00
Prospector
b59f208e91 Update changelog 2025-05-08 08:50:29 -07:00
Prospector
8fdc7403b1 Allow unlimited items to show in multiselect for game version and loader selection Closes #1964 2025-05-07 19:43:33 -07:00
Prospector
895cd11e30 Updated changelog 2025-05-07 19:25:11 -07:00
Prospector
58ce3a4967 Fix Views showing Hidden instead of other 2025-05-07 19:24:31 -07:00
Prospector
bdd4deb302 Fix updateCurrentDate undefined 2025-05-07 19:24:21 -07:00
Prospector
ec2a56cd73 intl:extract 2025-05-07 19:18:13 -07:00
Prospector
10a5864a47 Update changelog 2025-05-07 19:17:12 -07:00
Prospector
16766be82f Add server unzipping (#3622)
* Initial unzipping feature

* Remove explicit backup provider naming from frontend

* CF placeholder

* Use regex for CF links

* Lint

* Add unzip warning for conflicting files, fix hydration error

* Adjust conflict modal ui

* Fix old queued ops sticking around, remove conflict warning

* Add vscode "editor.detectIndentation": true
2025-05-07 19:08:38 -07:00
Calum H.
1884410e0d fix: standardize relative timestamping (#3612)
* fix(frontend): relative timestamps are incorrectly rounded.

Closes: #1371

* fix(all): remove legacy fromNow for proper relative timestamp creation

Closes: #1395
2025-05-07 21:37:35 +00:00
Calum H.
6d57da2053 fix: notification dashboard issues (#3624)
* refactor: Notification helper refactor, fixes a lot of issues.

* fix: lint issues
2025-05-07 21:08:47 +00:00
Calum H.
e4adbb9469 fix: capitalization of ID org route breaks projects list (#3621)
Closes: #3615
2025-05-07 21:01:54 +00:00
Calum H.
8ee621295c fix: country composable not working due to nuxt routing. (#3623)
Closes: #2263
2025-05-07 21:01:25 +00:00
ThatGravyBoat
32920dd825 fix: changelog margins overlapping link (#3593)
* fix: changelog margins overlapping link

* dont start gradient 8rem away
2025-05-07 20:58:00 +00:00
Josiah Glosson
2d5d2d5df8 Fix to attached world data (#3618)
* Add AttachedWorldData::remove_for_world

* Box the S3 error for daedelus_client ErrorKind::S3

* Delete attached world data on world deletion
2025-05-06 15:06:46 +00:00
Alejandro González
8dd32bbe98 Fix macOS Theseus build issue, cleanup platform specific code (#3604)
* chore(theseus): significantly cleanup MacOS-specific code

* fix(labrinth): only use jemalloc allocator for Linux targets

The upstream crate asserts that its tests only pass for Linux targets,
and there's little point in supporting other OS for now since practical
Labrinth deployments run under a Linux environment anyway. This change
made it easier for me to cross-compile Labrinth.

* chore(theseus): tweak traffic lights pos according to c39bb78e38

As far as I understand it, that PR introduced the seemingly ad-hoc
additions of 6 and 12 units to the traffic light position calculations,
not directly modifying the `const` values introduced by
d6a72fbfc4.

* fix: re-enable app window shadows on Linux

* chore: log `window.set_shadow` errors

* chore: trigger CI
2025-05-05 19:38:10 +00:00
Emma Alexia
f71830e0fa Add helpful functions for interacting with db manually (#3602)
Brought over from modrinth/labrinth#862
2025-05-03 12:15:15 +00:00
ToBinio
bd0d6a9ac0 chore(theseus): add https://*.githubusercontent.com to frame-src (#1298)
* chore: add https://*.githubusercontent.com to frame-src

* chore(app): move `githubusercontent.com` CSP allowance to the proper media category

---------

Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-05-02 16:53:52 +00:00
Prospector
dfef0df464 Update changelog
(cherry picked from commit ca2179c163244b2ed45fde57e5c9408a6b70605a)
2025-05-02 09:24:14 -07:00
Alejandro González
9821737431 chore: fix Docker build (#3594) 2025-05-02 15:40:34 +00:00
Erb3
f932ce7706 chore: move to .env.example (#3592)
* chore: move to .env.example

* docs: meniton copying .env.example

* chore: app-lib env example

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>

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

---------

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>
2025-05-02 14:20:40 +00:00
Josiah Glosson
de3019e92b Theseus build updates (#3588)
* Add launcher_feature_version to Profile

* Misc fixes

- Add typing to theme and settings stuff
- Push instance route on creation from installing a modpack
- Fixed servers not reloading properly when first added

* Make old instances scan the logs folder for joined servers on launcher startup

* Create AttachedWorldData

* Change AttachedWorldData interface

* Rename WorldType::World to WorldType::Singleplayer

* Implement world display status system

* Fix Minecraft font

* Fix set_world_display_status Tauri error

* Add 'Play instance' option

* Add option to disable worlds showing in Home

* Fixes

- Fix available server filter only showing if there are some available
- Fixed server and singleplayer filters sometimes showing when there are only servers or singleplayer worlds
- Fixed new worlds not being automatically added when detected
- Rephrased Jump back into worlds option description

* Fixed sometimes more than 6 items showing up in Jump back in

* Fix servers.dat issue with instances you haven't played before

* Update a bunch of app dependencies in non-breaking ways

* Update dependencies in app-lib that had breaking updates

* Update dependencies in app that had breaking updates

* Fix too large of bulk requests being made, limit max to 800 #3430

* Also update tauri-plugin-opener

* Update app-lib to Rust 2024

* Non-breaking updates in ariadne

* Breaking updates in ariadne

* Ariadne Rust 2024

* Add hiding from home page, add types to Mods.vue

* Make recent worlds go into grid when display is huge

* Fix lint

* Remove redundant media query

* Fix protocol version on home page, and home page being blocked by pinging servers

* Clippy fix in app-lib

* Clippy fix in app

* Clippy fix

* More Clippy fixes

* Fix Prettier lints

* Undo `from_string` changes

* Update macos dependencies

* Apply updates to app-playground as well

* Update Wry + Tauri

* Update sysinfo

* Update theseus_gui to Rust 2024

* Downgrade rand in ariadne to fix labrinth

Labrinth can't use rand 0.9 due to argon2

* Cargo format

---------

Signed-off-by: Josiah Glosson <soujournme@gmail.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Alejandro González <me@alegon.dev>
2025-05-02 09:51:17 +00:00
Prospector
20b616a7c4 Update changelog 2025-05-01 18:25:46 -07:00
Prospector
4180544e0a Bump version 2025-05-01 17:49:31 -07:00
Prospector
9168d349fc Fix filter bar showing up with no options 2025-05-01 16:27:18 -07:00
Prospector
3dad6b317f MR App 0.9.5 - Big bugfix update (#3585)
* Add launcher_feature_version to Profile

* Misc fixes

- Add typing to theme and settings stuff
- Push instance route on creation from installing a modpack
- Fixed servers not reloading properly when first added

* Make old instances scan the logs folder for joined servers on launcher startup

* Create AttachedWorldData

* Change AttachedWorldData interface

* Rename WorldType::World to WorldType::Singleplayer

* Implement world display status system

* Fix Minecraft font

* Fix set_world_display_status Tauri error

* Add 'Play instance' option

* Add option to disable worlds showing in Home

* Fixes

- Fix available server filter only showing if there are some available
- Fixed server and singleplayer filters sometimes showing when there are only servers or singleplayer worlds
- Fixed new worlds not being automatically added when detected
- Rephrased Jump back into worlds option description

* Fixed sometimes more than 6 items showing up in Jump back in

* Fix servers.dat issue with instances you haven't played before

* Fix too large of bulk requests being made, limit max to 800 #3430

* Add hiding from home page, add types to Mods.vue

* Make recent worlds go into grid when display is huge

* Fix lint

* Remove redundant media query

* Fix protocol version on home page, and home page being blocked by pinging servers

* Clippy fix

* More Clippy fixes

* Fix Prettier lints

* Undo `from_string` changes

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Co-authored-by: Alejandro González <me@alegon.dev>
2025-05-01 16:13:13 -07:00
Prospector
4a2605bc1e Add ability to switch payment interval for Modrinth+ (#3581) 2025-05-01 17:36:51 +00:00
jade
41543e3af0 Use project/user/org permalinks in moderation queue page (#3586) 2025-05-01 00:46:54 +00:00
Prospector
6003f1a10e Update changelog 2025-04-29 08:13:36 -07:00
Prospector
3d9be0cc3f Fix duplicate hidden entries in analytics (#3576) 2025-04-29 08:12:38 -07:00
Prospector
5e7444f115 intl:extract 2025-04-28 19:42:16 -07:00
Prospector
20fcf70e90 Update changelog, fix overflowing maven coords 2025-04-28 19:41:08 -07:00
Emma Alexia
0508f13cb6 Quick moderation fixes (#3556)
* Quick moderation fixes

* Fix Odyssey mods linking
* Add "Copy permanent link" button to orgs, users, projects
* Use permanent links for Slack webhooks

* Update apps/frontend/src/pages/organization/[id].vue

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>

* Run Prettier

---------

Signed-off-by: Emma Alexia <wafflecoffee7@gmail.com>
Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-04-29 01:18:43 +00:00
Emma Alexia
2f68c62b3a Improve wording about unprovisioned servers (#3574) 2025-04-29 01:13:44 +00:00
Prospector
ea64e08791 Add support for snapshots with Modrinth Servers (#3570)
* Add support for snapshots with Modrinth Servers

* Fix snapshots without dots

* Fix loader version not resetting when no longer valid

* Fix collapsing margins on Report page
2025-04-28 18:14:04 -07:00
Alejandro González
6f485d62ad Simplify Mac app download links (#3519)
* tweak(frontend/app): simplify download links, remove dead code

* chore: apply @triphora's suggestion

Co-authored-by: Emma Alexia <emma@modrinth.com>
Signed-off-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>

---------

Signed-off-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Co-authored-by: Emma Alexia <emma@modrinth.com>
2025-04-27 23:03:13 +00:00
Prospector
362fc11c81 Update changelog for 0.9.4 2025-04-26 19:28:44 -07:00
Jai A
9e9fb0a9fc Bump app version + update posthog URL 2025-04-26 18:14:17 -07:00
Prospector
ff4c7f47b2 Direct World Joining (#3457)
* Begin work on worlds backend

* Finish implementing get_profile_worlds and get_server_status (except pinning)

* Create TS types and manually copy unparsed chat components

* Clippy fix

* Update types.d.ts

* Initial worlds UI work

* Fix api::get_profile_worlds to take in a relative path

* sanitize & security update

* Fix sanitizePotentialFileUrl

* Fix sanitizePotentialFileUrl (for real)

* Fix empty motd causing error

* Finally actually fix world icons

* Fix world icon not being visible on non-Windows

* Use the correct generics to take in AppHandle

* Implement start_join_singleplayer_world and start_join_server for modern versions

* Don't error if server has no cached icon

* Migrate to own server pinging

* Ignore missing server hidden field and missing saves dir

* Update world list frontend

* More frontend work

* Server status player sample can be absent

* Fix refresh state

* Add get_profile_protocol_version

* Add protocol_version column to database

* SQL INTEGER is i64 in sqlx

* sqlx prepare

* Cache protocol version in database

* Continue worlds UI work

* Fix motds being bold

* Remove legacy pinging and add a 30-second timeout

* Remove pinned for now and match world (and server) parsing closer to spec

* Move type ServerStatus to worlds.ts

* Implement add_server_to_profile

* Fix pack_status being ignored when joining from launcher

* Make World path field be relative

* Implement rename_world and reset_world_icon

* Clippy fix

* Fix rename_world

* UI enhancements

* Implement backup_world, which returns the backup size in bytes

* Clippy fix

* Return index when adding servers to profile

* Fix backup

* Implement delete_world

* Implement edit_server_in_profile and remove_server_from_profile

* Clippy fix

* Log server joins

* Add edit and delete support

* Fix ts errors

* Fix minecraft font

* Switch font out for non-monospaced.

* Fix font proper

* Some more world cleanup, handle play state, check quickplay compatibility

* Clear the cached protocol version when a profile's game version is changed

* Fix tint colors in navbar

* Fix server protocol version pinging

* UI fixes

* Fix protocol version handler

* Fix MOTD parsing

* Add worlds_updated profile event

* fix pkg

* Functional home screen with worlds

* lint

* Fix incorrect folder creation

* Make items clickable

* Add locked field to SingleplayerWorld indicating whether the world is locked by the game

* Implement locking frontend

* Fix locking condition

* Split worlds_updated profile event into servers_updated and world_updated

* Fix compile error

* Use port from resolve SRV record

* Fix serialization of ProfilePayload and ProfilePayloadType

* Individual singleplayer world refreshing

* Log when worlds are perceived to be updated

* Push logging + total refresh lock

* Unlisten fixes

* Highlight current world when clicked

* Launcher logs refactor (#3444)

* Switch live log to use STDOUT

* fix clippy, legacy logs support

* Fix lint

* Handle non-XML log messages in XML logging, and don't escape log messages into XML

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>

* Update incompatibility text

* Home page fixes, and unlock after close

* Remove logging

* Add join log database migration

* Switch server join timing to being in the database instead of in a separate log file

* Create optimized get_recent_worlds function that takes in a limit

* Update dependencies and fix Cargo.lock

* temp disable overflow menus

* revert home page changes

* Enable overflow menus again

* Remove list

* Revert

* Push dev tools

* Remove default filter

* Disable debug renderer

* Fix random app errors

* Refactor

* Fix missing computed import

* Fix light mode issues

* Fix TS errors

* Lint

* Fix bad link in change modpack version modal

* fix lint

* fix intl

---------

Co-authored-by: Josiah Glosson <soujournme@gmail.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
2025-04-26 18:09:58 -07:00
Prospector
25016053ca Update changelog 2025-04-25 19:41:41 -07:00
Emma Alexia
f9c0c1bc53 Clarify that Modrinth Servers prices are in USD (#3553)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-04-26 02:35:30 +00:00
Prospector
73e54a5fbb Add Servers cancellation survey (#3551) 2025-04-25 19:31:36 +00:00
Erb3
6f902e2107 feat(labrinth): environment variables for more customizable SMTP (#2886)
* refactor: move .env to .env.example

* refactor(labrinth): allow setting SMTP port and TLS

This will help setting up labrinth for local development. You can now use a mock SMTP server such as smtp4dev. The TLS options will stay the same as before if set to `true`, and disabled when `false`.

Depends on #2883

* chore(labrinth): lint

* chore(labrinth): conflicts

* chore(labrinth): conflicts

* fix: use TLS port by default

Co-authored-by: AlexTMjugador<me@alegon.dev>

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

* fix(labrinth): correct deafult SMTP port in .env

* feat(labrinth): expose all SMTP TLS settings

Replaced if/else with a switch statement. The new values for `SMPT_TLS` are `none`, `opportunistic_start_tls`, `requires_start_tls`, `tls`. When none of these values are supplied, it defaults to full TLS (`tls`), and throws a warning.

Resolves PR review

* fix(labrinth): correct SMTP TLS example .env setting

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

* fix(labrinth) SMTP tls env var check

Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

---------

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>
Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com>
2025-04-22 11:29:14 +00:00
881ce11ab8 Update .github/workflows/theseus-release.yml 2025-04-21 20:26:29 +03:00
Alejandro González
ecb1379585 enh(labrinth): proper Clickhouse user setup for local development (#3545) 2025-04-21 15:44:24 +00:00
Alejandro González
068711e7a9 enh(labrinth): disable hCaptcha verification when secret is unset (#3544) 2025-04-21 15:42:17 +00:00
maksimetny
f695fe0ee7 Adds drag region support for touch screens (#3178)
https://github.com/tauri-apps/tauri/issues/4746#issuecomment-2007114269

Signed-off-by: maksimetny <46288028+maksimetny@users.noreply.github.com>
2025-04-19 19:18:37 +00:00
3d3038323c Bump to v0.9.306 2025-04-19 18:01:41 +03:00
Aaron Müller
6cdc07406d Use the new ConfirmModal (#3458)
* style: copy from old modal

* chore: move to new modals

* eslint: fix sorting

---------

Signed-off-by: Aaron Müller <160637865+amueller0@users.noreply.github.com>
2025-04-19 14:50:30 +00:00
c63dca6c2b Merge commit 'daf699911104207a477751916b36a371ee8f7e38' into feature-clean 2025-04-19 17:29:54 +03:00
Erb3
daf6999111 Document usage of OAuth (#3342)
* docs: hitchhiker's guide to OAuth

* docs: remove old OAuth guide from OpenApi spec

* fixup! docs: remove old OAuth guide from OpenApi spec

* docs: mention /user endpoint in oauth

* docs: oauth flow overview

* docs: mention PAT

* docs: fix reviews

* docs: support portal over github issue

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

---------

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>
2025-04-19 13:51:01 +00:00
Magnus Jensen
42731521f1 fix: instance project author link not checking for organizations (#3315)
Co-authored-by: Emma Alexia <emma@modrinth.com>
2025-04-19 13:44:46 +00:00
Calum H.
182119aedf feat: modrinth maven developer mode additions (#3498)
* feat: modrinth maven copy string in developer mode

* feat: Modrinth maven in versions list dropdown for developer mode.

* fix: lint
2025-04-19 13:10:34 +00:00
MikeyPants
59e18b3104 fix(apps/frontend): error data can be undefined (#3513) 2025-04-19 13:05:43 +00:00
Emma Alexia
5c1f198397 Add ability to delete user icon (#3383)
* Add user icon delete route

By request of moderation, but also just generally nice to have

* Add relevant docs and frontend

* Add v2 version
2025-04-19 12:49:23 +00:00
Emma Alexia
3cd6718384 Fix inverted condition with refunds (#3539)
Signed-off-by: Emma Alexia <emma@modrinth.com>
2025-04-19 12:25:43 +00:00
Prospector
1903980b71 Update Servers marketing page (#3535)
* Update Servers marketing page

* Add burst FAQ

* Updated phrasing again

* Fix servers page when not logged in

* Update changelog
2025-04-18 22:23:30 -07:00
Emma Alexia
84a28e045b Allow servers to be unprovisioned without issuing a refund (#3534)
* Allow servers to be unprovisioned without issuing a refund

for very specific weird circumstances where a server gets stuck/etc; useful for support

* still create a charge

* Fix compile
2025-04-19 04:39:18 +00:00
Emma Alexia
d0aef27f7b Fix deleting a user that has a prior subscription (#3533)
(for real this time)
2025-04-19 04:38:54 +00:00
Prospector
d6a74b0cfe Fix prepare initiating overriding states, and add handling of failed file prep 2025-04-17 02:56:26 -07:00
Prospector
0c43eb0d22 Update changelog date 2025-04-17 02:19:06 -07:00
Sticks
f8494030aa backup page fixes and new impls for new apis (#3437)
* wip: backup page fixes and new impls for new apis

* wip: more progress on backup fixes, almost done

* lint

* Backups cleanup

* Don't show create warning if creating

* Fix ongoing state

* Download support

* Support ready

* Disable auto backup button

* Use auth param for download of backups

* Disable install buttons when backup is in progress, add retrying

* Make prepare button have immediate feedback, don't refresh backups in all cases

* Intl:extract & rebase fixes

* Updated changelog and fix lint

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-04-17 01:26:13 -07:00
Prospector
817151e47c Add notice dismissed count to dashboard 2025-04-15 18:11:32 -07:00
Prospector
d5dfb609cf Update changelog 2025-04-15 16:31:52 -07:00
Prospector
09aae0edc9 Update changelog 2025-04-15 16:31:52 -07:00
Prospector
6aa6db4e8c Survey notices for Servers (#3514)
* Survey notices for Servers

* lint

* remove creepy frog
2025-04-15 16:29:50 -07:00
Jai Agrawal
76be502e16 Make connections short-lived redis (#3509) 2025-04-15 15:39:13 -07:00
Prospector
04659a8198 Notices fixes and support for titles (#3508)
* Notices fixes and support for titles

* Lint
2025-04-14 16:58:31 -07:00
Prospector
6c16688ca9 Remove notice limit 2025-04-12 22:23:45 -07:00
Emma Alexia
f2ec89e62b Fix two database errors (#3483)
* Fixes error when an admin tries transferring project ownership
* Fixes error when trying to delete a user when they previously have a transaction

Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
2025-04-12 22:21:02 -07:00
Prospector
edd09b0b16 Update changelog 2025-04-12 22:06:22 -07:00
Prospector
59edc8d618 Add notices system to Servers (#3502)
* Servers notices

* Refresh on unassign
2025-04-12 22:00:22 -07:00
Aaron Müller
56520572b2 fix: dropdown import (#3459)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Emma Alexia <emma@modrinth.com>
2025-04-12 16:22:16 +00:00
Alejandro González
487bdd1e48 chore: remove unused Rust dependencies (#3492) 2025-04-12 15:42:45 +00:00
Tiziano
8ad5e011ca Update 1-app-bug.yml (#3486)
Signed-off-by: Tiziano <69322987+T1xx1@users.noreply.github.com>
2025-04-12 15:22:41 +00:00
un_pogaz
6f43fc272b fix: use local assets for "apps/labrinth/README.md" (#3489) 2025-04-12 15:21:20 +00:00
Alejandro González
e008b657a5 Fix Clippy lints (#3494)
* chore: fix some Clippy lints

* chore(labrinth): more Clippy fixes
2025-04-12 13:45:17 +00:00
Emma Alexia
365367dd16 Hide collections with no projects from public view (#3408)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-04-06 20:54:18 +00:00
5c09f31383 Bump to v0.9.305 2025-04-06 04:05:12 +03:00
d527c3e1ba Merge commit '36367e475e1849445718a2ecec8ee5b74110ff08' into feature-clean 2025-04-05 21:14:26 +03:00
Jai A
36367e475e fix margin 2025-04-04 00:49:58 -07:00
Jai A
13f2961e43 run migrate 2025-04-03 17:47:30 -07:00
Jai A
69b70d70a8 Remove lock, fix billing job 2025-04-03 16:14:27 -07:00
Jai A
d0d0dcf09f Fix billing setup 2025-04-03 15:44:53 -07:00
Prospector
41b9729b9b Update changelog 2025-04-01 21:03:40 -07:00
Prospector
a2009cae39 Revert "fixed a bug"
This reverts commit 49faba6ad2.
2025-04-01 20:54:05 -07:00
Prospector
fab086b3e1 Revert "favicon"
This reverts commit e8f8be1940.
2025-04-01 20:54:05 -07:00
Prospector
f379126242 Revert "more improvements (#3449)"
This reverts commit 3d2cef40d5.
2025-04-01 20:54:04 -07:00
Prospector
8e0d9f2da6 Revert "Add craftmine support"
This reverts commit 4624a29332.
2025-04-01 20:54:04 -07:00
Prospector
e931b5c8ef Revert "Pizza highlight"
This reverts commit 9024b2eec5.
2025-04-01 20:54:04 -07:00
Prospector
84617d0c49 Revert "bypass discord cache"
This reverts commit 9b442d04d9.
2025-04-01 20:54:04 -07:00
Prospector
0908cf4e94 Revert "use modrinth links"
This reverts commit 916f27c5ab.
2025-04-01 20:54:04 -07:00
Prospector
916f27c5ab use modrinth links 2025-04-01 10:32:52 -07:00
Prospector
9b442d04d9 bypass discord cache 2025-04-01 09:55:29 -07:00
Prospector
9024b2eec5 Pizza highlight 2025-04-01 09:51:04 -07:00
Prospector
4624a29332 Add craftmine support 2025-04-01 09:50:00 -07:00
Sticks
3d2cef40d5 more improvements (#3449)
* more improvements

* fix: apply pnpm run fix
2025-03-31 20:34:49 -07:00
Prospector
e8f8be1940 favicon 2025-03-31 17:53:57 -07:00
Prospector
49faba6ad2 fixed a bug 2025-03-31 17:21:31 -07:00
1080 changed files with 45288 additions and 20315 deletions

View File

@@ -1,6 +1,6 @@
# Windows has stack overflows when calling from Tauri, so we increase compiler size
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220"]
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[build]
rustflags = ["--cfg", "tokio_unstable"]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -14,5 +14,5 @@ max_line_length = 100
max_line_length = off
trim_trailing_whitespace = false
[*.rs]
indent_size = 4
[*.{rs,java,kts}]
indent_size = 4

34
.gitattributes vendored
View File

@@ -1 +1,35 @@
* text=auto eol=lf
# SQLx calculates a checksum of migration scripts at build time to compare
# it with the checksum of the applied migration for the same version at
# runtime, to know if the migration script has been changed, and thus the
# DB schema went out of sync with the code.
#
# However, such checksum treats the script as a raw byte stream, taking
# into account inconsequential differences like different line endings
# in different OSes. When combined with Git's EOL conversion and mixed
# native and cross-compilation scenarios, this leads to existing
# migrations that didn't change having potentially different checksums
# according to the environment they were built in, which can break the
# migration system when deploying the Modrinth App, rendering it
# unusable.
#
# The gitattribute above ensures that all text files are checked out
# with LF line endings, but widely deployed app versions were built
# without this attribute set, which left such line endings variable to
# the platform. Thus, there is no perfect solution to this problem:
# forcing CRLF here would break Linux and macOS users, forcing LF
# breaks Windows users, and leaving it unspecified may still lead to
# line ending differences when cross-compiling from Linux to Windows
# or vice versa, or having Git configured with different line
# conversion settings. Moreover, there is no `eol=native` attribute,
# and using CI-only scripts to convert line endings would make the
# builds differ between CI and most local environments. So, let's pick
# the least bad option: let Git handle line endings using its
# configuration by leaving it unspecified, which works fine as long as
# people don't mess with Git's line ending settings, which is the vast
# majority of cases.
/packages/app-lib/migrations/20240711194701_init.sql !eol
/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol
/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol
/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol

BIN
.github/assets/api_cover.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 24 KiB

118
.github/workflows/astralrinth-build.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: AstralRinth App build
on:
push:
branches:
- main
- feature*
tags:
- 'v*'
paths:
- .github/workflows/astralrinth-build.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
jobs:
build:
name: Build
strategy:
fail-fast: false
matrix:
# platform: [macos-latest, windows-latest, ubuntu-latest]
platform: [ubuntu-latest]
include:
# - platform: macos-latest
# artifact-target-name: universal-apple-darwin
# - platform: windows-latest
# artifact-target-name: x86_64-pc-windows-msvc
- platform: ubuntu-latest
artifact-target-name: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- name: 📥 Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: ${{ matrix.artifact-target-name }}
- name: 🧰 Install pnpm
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: 🧰 Install Linux build dependencies
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -yq \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
xdg-utils \
openjdk-11-jdk
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
run: pnpm install
# - name: ✍️ Set up Windows code signing (jsign)
# if: matrix.platform == 'windows-latest' && env.SIGN_WINDOWS_BINARIES == 'true'
# shell: bash
# run: |
# choco install jsign --ignore-dependencies
- name: 🗑️ Clean up cached bundles
shell: bash
run: |
rm -rf target/release/bundle
rm -rf target/*/release/bundle || true
# - name: 🔨 Build macOS app
# if: matrix.platform == 'macos-latest'
# run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
# env:
# TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Linux app
if: matrix.platform == 'ubuntu-latest'
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# - name: 🔨 Build Windows app
# if: matrix.platform == 'windows-latest'
# shell: pwsh
# run: |
# $env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
# pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis'
# env:
# TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 📤 Upload app bundles
uses: actions/upload-artifact@v3
with:
name: App bundle (${{ matrix.artifact-target-name }})
path: |
target/release/bundle/**
target/*/release/bundle/**

View File

@@ -1,151 +0,0 @@
name: 'AstralRinth App build'
on:
push:
branches:
- feature*
tags:
- 'build*'
- 'v*'
paths:
- .github/workflows/theseus-release.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'apps/labrinth/src/common/**'
- 'apps/labrinth/Cargo.toml'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest, ubuntu-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Rust setup (mac)
if: startsWith(matrix.platform, 'macos')
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: aarch64-apple-darwin, x86_64-apple-darwin
- name: Rust setup
if: "!startsWith(matrix.platform, 'macos')"
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Setup rust cache
uses: actions/cache@v4
with:
path: |
target/**
!target/*/release/bundle/*/*.dmg
!target/*/release/bundle/*/*.app.tar.gz
!target/*/release/bundle/*/*.app.tar.gz.sig
!target/release/bundle/*/*.dmg
!target/release/bundle/*/*.app.tar.gz
!target/release/bundle/*/*.app.tar.gz.sig
!target/release/bundle/appimage/*.AppImage
!target/release/bundle/appimage/*.AppImage.tar.gz
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
!target/release/bundle/deb/*.deb
!target/release/bundle/rpm/*.rpm
!target/release/bundle/msi/*.msi
!target/release/bundle/msi/*.msi.zip
!target/release/bundle/msi/*.msi.zip.sig
!target/release/bundle/nsis/*.exe
!target/release/bundle/nsis/*.nsis.zip
!target/release/bundle/nsis/*.nsis.zip.sig
key: ${{ runner.os }}-rust-target-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-rust-target-
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm via corepack
shell: bash
run: |
corepack enable
corepack prepare --activate
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: install dependencies (ubuntu only)
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev
- name: Install frontend dependencies
run: pnpm install
- name: build app (macos)
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config "tauri-release.conf.json"
if: startsWith(matrix.platform, 'macos')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: build app
run: pnpm --filter=@modrinth/app run tauri build --config "tauri-release.conf.json"
id: build_os
if: "!startsWith(matrix.platform, 'macos')"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: upload ${{ matrix.platform }}
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}
path: |
target/*/release/bundle/*/*.dmg
target/*/release/bundle/*/*.app.tar.gz
target/*/release/bundle/*/*.app.tar.gz.sig
target/release/bundle/*/*.dmg
target/release/bundle/*/*.app.tar.gz
target/release/bundle/*/*.app.tar.gz.sig
target/release/bundle/*/*.AppImage
target/release/bundle/*/*.AppImage.tar.gz
target/release/bundle/*/*.AppImage.tar.gz.sig
target/release/bundle/*/*.deb
target/release/bundle/*/*.rpm
target/release/bundle/msi/*.msi
target/release/bundle/msi/*.msi.zip
target/release/bundle/msi/*.msi.zip.sig
target/release/bundle/nsis/*.exe
target/release/bundle/nsis/*.nsis.zip
target/release/bundle/nsis/*.nsis.zip.sig

2
.idea/code.iml generated
View File

@@ -17,4 +17,4 @@
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
</module>

2
.idea/modules.xml generated
View File

@@ -5,4 +5,4 @@
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
</modules>
</component>
</project>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,5 +1,11 @@
<?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>

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20.19.2

View File

@@ -2,6 +2,7 @@
"prettier.endOfLine": "lf",
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}

5092
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,233 @@
[workspace]
resolver = '2'
resolver = "2"
members = [
'./packages/app-lib',
'./apps/app-playground',
'./apps/app',
'./apps/labrinth',
'./apps/daedalus_client',
'./packages/daedalus',
'./packages/ariadne',
"apps/app",
"apps/app-playground",
"apps/daedalus_client",
"apps/labrinth",
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
]
[workspace.package]
edition = "2024"
[workspace.dependencies]
actix-cors = "0.7.1"
actix-files = "0.6.6"
actix-http = "3.11.0"
actix-multipart = "0.7.2"
actix-rt = "2.10.0"
actix-web = "4.11.0"
actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17"
async-compression = { version = "0.4.25", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.88"
async-tungstenite = { version = "0.29.1", default-features = false, features = [
"futures-03-sink",
] }
async-walkdir = "2.1.0"
base64 = "0.22.1"
bitflags = "2.9.1"
bytemuck = "1.23.0"
bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41"
clap = "4.5.40"
clickhouse = "0.13.3"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
data-url = "0.3.1"
deadpool-redis = "0.21.1"
dirs = "6.0.0"
discord-rich-presence = "0.2.5"
dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
encoding_rs = "0.8.35"
enumset = "1.1.6"
flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
hashlink = "0.10.0"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper-tls = "0.6.0"
hyper-util = "0.1.14"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0"
indicatif = "0.17.11"
itertools = "0.14.0"
jemalloc_pprof = "0.7.0"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.17", default-features = false, features = [
"builder",
"hostname",
"pool",
"ring",
"rustls",
"rustls-native-certs",
"smtp-transport",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.28.0", default-features = false }
murmur2 = "0.1.0"
native-dialog = "0.9.0"
notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2"
paste = "1.0.15"
png = "0.17.16"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.37.5"
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
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
regex = "1.11.1"
reqwest = { version = "0.12.20", default-features = false }
rgb = "0.8.50"
rust_decimal = { version = "1.37.2", features = [
"serde-with-float",
"serde-with-str",
] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
] }
rusty-money = "0.4.1"
sentry = { version = "0.41.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"panic",
"reqwest",
"rustls",
] }
sentry-actix = "0.41.0"
serde = "1.0.219"
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.140"
serde_with = "3.13.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.8"
sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false }
tar = "0.4.44"
tauri = "2.6.1"
tauri-build = "2.3.0"
tauri-plugin-deep-link = "2.4.0"
tauri-plugin-dialog = "2.3.0"
tauri-plugin-http = "2.5.0"
tauri-plugin-opener = "2.4.0"
tauri-plugin-os = "2.3.0"
tauri-plugin-single-instance = "2.3.0"
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls",
"zip",
] }
tauri-plugin-window-state = "2.3.0"
tempfile = "3.20.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.45.1"
tokio-stream = "0.1.17"
tokio-util = "0.7.15"
totp-rs = "5.7.0"
tracing = "0.1.41"
tracing-actix-web = "0.7.18"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
url = "2.5.4"
urlencoding = "2.1.3"
uuid = "1.17.0"
validator = "0.20.0"
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "4.2.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
"zstd",
] }
zxcvbn = "3.1.0"
[workspace.lints.clippy]
bool_to_int_with_if = "warn"
borrow_as_ptr = "warn"
cfg_not_test = "warn"
clear_with_drain = "warn"
cloned_instead_of_copied = "warn"
collection_is_never_read = "warn"
dbg_macro = "warn"
default_trait_access = "warn"
explicit_iter_loop = "warn"
filter_map_next = "warn"
flat_map_option = "warn"
format_push_string = "warn"
get_unwrap = "warn"
large_include_file = "warn"
large_stack_arrays = "warn"
manual_assert = "warn"
manual_instant_elapsed = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
map_unwrap_or = "warn"
match_bool = "warn"
needless_collect = "warn"
negative_feature_names = "warn"
non_std_lazy_statics = "warn"
pathbuf_init_then_push = "warn"
read_zero_byte_vec = "warn"
redundant_clone = "warn"
redundant_feature_names = "warn"
redundant_type_annotations = "warn"
todo = "warn"
unnested_or_patterns = "warn"
wildcard_dependencies = "warn"
[workspace.lints.rust]
# Turn warnings into errors by default
warnings = "deny"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
# Optimize for speed and reduce size on release builds
[profile.release]
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
lto = true # Enables link to optimizations
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
lto = true # Enables link to optimizations
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
[profile.dev.package.sqlx-macros]
opt-level = 3
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }

View File

@@ -17,7 +17,7 @@ Introducing AstralRinth, a specialized variant of Theseus dedicated to implement
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
- 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://github.com/DIDIRUS4/AstralRinth/releases)
- 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
@@ -45,7 +45,7 @@ Dive into the extraordinary world of AstralRinth, a fork of the original project
- Using the launcher is intuitive, any user can figure it out.
### Update notifies
- We have implemented notifications about the release of new updates on our Github. The launcher can also download them for you and try to install them.
- 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.
### Enhancements
- Custom .SVG vectors for a personalized touch.

View File

@@ -1 +1,2 @@
**/dist
*.gltf

View File

@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "0.9.3",
"version": "1.0.0-local",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,25 +9,31 @@
"tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .",
"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/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",
"test": "vue-tsc --noEmit"
},
"dependencies": {
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-window-state": "^2.2.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
"ofetch": "^1.3.4",
"pinia": "^2.1.7",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.13",
"vue-multiselect": "3.0.0",
@@ -38,11 +44,12 @@
"@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12",
"@nuxt/eslint-config": "^0.5.6",
"@taijased/vue-render-tracker": "^1.0.7",
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19",
"eslint": "^9.9.1",
"eslint-config-custom": "workspace:*",
"eslint-plugin-turbo": "^2.1.1",
"eslint-plugin-turbo": "^2.5.4",
"postcss": "^8.4.39",
"prettier": "^3.2.5",
"sass": "^1.74.1",

View File

@@ -1,8 +1,9 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
CompassIcon,
DownloadIcon,
HomeIcon,
@@ -16,14 +17,23 @@ import {
RestoreIcon,
RightArrowIcon,
SettingsIcon,
WorldIcon,
XIcon,
NewspaperIcon,
} from '@modrinth/assets'
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
import {
Avatar,
Button,
ButtonStyled,
Notifications,
OverflowMenu,
NewsArticleCard,
} from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
// import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get, set } from '@/helpers/settings'
import { get, set } from '@/helpers/settings.ts'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -54,12 +64,13 @@ import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import dayjs from 'dayjs'
// import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
// import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
const themeStore = useTheming()
@@ -177,25 +188,53 @@ async function setupApp() {
}),
)
// Patched by AstralRinth
// useFetch(
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
// 'criticalAnnouncements',
// true,
// ).then((res) => {
// if (res && res.header && res.body) {
// criticalErrorMessage.value = res
// }
// })
// )
// .then((response) => response.json())
// .then((res) => {
// if (res && res.header && res.body) {
// criticalErrorMessage.value = res
// }
// })
// .catch(() => {
// console.log(
// `No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
// )
// })
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
if (res && res.articles) {
news.value = res.articles
}
})
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true)
.then((response) => response.json())
.then((res) => {
if (res && res.articles) {
// Format expected by NewsArticleCard component.
news.value = res.articles
.map((article) => ({
...article,
path: article.link,
thumbnail: article.thumbnail,
title: article.title,
summary: article.summary,
date: article.date,
}))
.slice(0, 4)
}
})
get_opening_command().then(handleCommand)
// checkUpdates()
fetchCredentials()
try {
const skins = (await get_available_skins()) ?? []
const capes = (await get_available_capes()) ?? []
generateSkinPreviews(skins, capes)
} catch (error) {
console.warn('Failed to generate skin previews in app setup.', error)
}
}
const stateFailed = ref(false)
@@ -303,6 +342,7 @@ onMounted(() => {
})
const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand)
async function handleCommand(e) {
@@ -374,7 +414,7 @@ function handleAuxClick(e) {
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div id="teleports"></div>
<div v-if="stateInitialized" class="app-grid-layout relative">
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
@@ -387,6 +427,9 @@ function handleAuxClick(e) {
<NavButton v-tooltip.right="'Home'" to="/">
<HomeIcon />
</NavButton>
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
to="/browse/modpack"
@@ -395,6 +438,9 @@ function handleAuxClick(e) {
>
<CompassIcon />
</NavButton>
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
<ChangeSkinIcon />
</NavButton>
<NavButton
v-tooltip.right="'Library'"
to="/library"
@@ -488,7 +534,7 @@ function handleAuxClick(e) {
<RunningAppBar />
</Suspense>
</div>
<section v-if="!nativeDecorations" class="window-controls">
<section v-if="!nativeDecorations" class="window-controls" data-tauri-drag-region-exclude>
<Button class="titlebar-button" icon-only @click="() => getCurrentWindow().minimize()">
<MinimizeIcon />
</Button>
@@ -575,34 +621,20 @@ function handleAuxClick(e) {
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense>
</div>
<div v-if="news && news.length > 0" class="pt-4 flex flex-col">
<h3 class="px-4 text-lg m-0">News</h3>
<template v-for="(item, index) in news" :key="`news-${index}`">
<a
:class="`flex flex-col outline-offset-[-4px] hover:bg-[--brand-gradient-border] focus:bg-[--brand-gradient-border] px-4 transition-colors ${index === 0 ? 'pt-2 pb-4' : 'py-4'}`"
:href="item.link"
target="_blank"
rel="external"
>
<img
:src="item.thumbnail"
alt="News thumbnail"
aria-hidden="true"
class="w-full aspect-[3/1] object-cover rounded-2xl border-[1px] border-solid border-[--brand-gradient-border]"
/>
<h4 class="mt-2 mb-0 text-sm leading-none text-contrast font-semibold">
{{ item.title }}
</h4>
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
{{ dayjs(item.date).fromNow() }}
</p>
</a>
<hr
v-if="index !== news.length - 1"
class="h-px my-[-2px] mx-4 border-0 m-0 bg-[--brand-gradient-border]"
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
:key="`news-${index}`"
:article="item"
/>
</template>
<ButtonStyled color="brand" size="large">
<a href="https://modrinth.com/news" target="_blank" class="my-4">
<NewspaperIcon /> View all news
</a>
</ButtonStyled>
</div>
</div>
</div>
</div>
@@ -719,6 +751,14 @@ function handleAuxClick(e) {
grid-area: status;
}
[data-tauri-drag-region] {
-webkit-app-region: drag;
}
[data-tauri-drag-region-exclude] {
-webkit-app-region: no-drag;
}
.app-contents {
position: absolute;
z-index: 1;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 937 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -2,8 +2,44 @@
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;
font-display: swap;
font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
}
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: italic;
font-display: swap;
font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
}
.font-minecraft {
font-family: 'bundled-minecraft-font-mrapp', monospace;
}
:root {
font-family: var(--font-standard);
font-family: var(--font-standard, sans-serif), sans-serif;
color-scheme: dark;
--view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem);

View File

@@ -10,7 +10,6 @@ import {
StopCircleIcon,
ExternalIcon,
EyeIcon,
ChevronRightIcon,
} from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import Instance from '@/components/ui/Instance.vue'
@@ -26,6 +25,7 @@ import { trackEvent } from '@/helpers/analytics'
import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js'
import { openUrl } from '@tauri-apps/plugin-opener'
import { HeadingLink } from '@modrinth/ui'
const router = useRouter()
@@ -44,7 +44,9 @@ const props = defineProps({
})
const actualInstances = computed(() =>
props.instances.filter((x) => x && x.instances && x.instances[0]),
props.instances.filter(
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
),
)
const modsRow = ref(null)
@@ -181,6 +183,10 @@ const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
if (rows.value.length === 0) {
return
}
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
@@ -204,16 +210,21 @@ const calculateCardsPerRow = () => {
const rowContainer = ref(null)
const resizeObserver = ref(null)
onMounted(() => {
calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
resizeObserver.value.observe(rowContainer.value)
if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value)
}
window.addEventListener('resize', calculateCardsPerRow)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow)
resizeObserver.value.unobserve(rowContainer.value)
if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value)
}
})
</script>
@@ -227,17 +238,10 @@ onUnmounted(() => {
@proceed="deleteProfile"
/>
<div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="(row, rowIndex) in actualInstances" ref="rows" :key="row.label" class="row">
<router-link
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group"
:class="{ 'mt-1': rowIndex > 0 }"
:to="row.route"
>
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route">
{{ row.label }}
<ChevronRightIcon
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
/>
</router-link>
</HeadingLink>
<section
v-if="row.instance"
ref="modsRow"

View File

@@ -9,15 +9,13 @@
<Avatar
size="36px"
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full">
<span>
<component v-if="selectedAccount" :is="getAccountType(selectedAccount)" class="vector-icon" />
{{ selectedAccount ? selectedAccount.username : 'Select account' }}
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}
</span>
<span class="text-secondary text-xs">Minecraft account</span>
</div>
@@ -27,36 +25,49 @@
<Card v-if="showCard || mode === 'isolated'" ref="card" class="account-card"
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }">
<div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.username}/128`" />
<Avatar size="xs" :src="avatarUrl" />
<div>
<h4>
<component :is="getAccountType(selectedAccount)" class="vector-icon" /> {{ selectedAccount.username }}
<component :is="getAccountType(selectedAccount)" class="vector-icon" /> {{ selectedAccount.profile.name }}
</h4>
<p>Selected</p>
</div>
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
<Button
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<TrashIcon />
</Button>
</div>
<div v-else class="login-section account">
<h4>Not signed in</h4>
<Button v-tooltip="'Log in'" icon-only @click="login()">
<MicrosoftIcon />
<Button
v-tooltip="'Log in'"
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
<MicrosoftIcon/>
</Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
<PirateIcon />
</Button>
</div>
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
<Button class="option account" @click="setAccount(account)">
<Avatar :src="`https://mc-heads.net/avatar/${account.username}/128`" class="icon" />
<Avatar :src="getAccountAvatarUrl(account)" class="icon" />
<p class="account-type">
<component :is="getAccountType(account)" class="vector-icon" />
{{ account.username }}
{{ account.profile.name }}
</p>
</Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)">
<TrashIcon />
</Button>
</div>
@@ -96,7 +107,16 @@
</template>
<script setup>
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, PirateIcon as Offline, MicrosoftIcon as License, MicrosoftIcon, PirateIcon } from '@modrinth/assets'
import {
DropdownIcon,
PlusIcon,
TrashIcon,
LogInIcon,
PirateIcon as Offline,
MicrosoftIcon as License,
MicrosoftIcon,
PirateIcon,
SpinnerIcon } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
@@ -111,7 +131,9 @@ import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js'
import ModalWrapper from './modal/ModalWrapper.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_available_skins } from '@/helpers/skins'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
defineProps({
mode: {
@@ -124,18 +146,19 @@ defineProps({
const emit = defineEmits(['change'])
const accounts = ref({})
const loginDisabled = ref(false)
const defaultUser = ref()
const loginOfflineModal = ref(null)
const loginErrorModal = ref(null)
const unexpectedErrorModal = ref(null)
const playerName = ref('')
async function tryOfflineLogin() { // Patched
async function tryOfflineLogin() { // Patched by AstralRinth
loginOfflineModal.value.show()
}
async function offlineLoginFinally() { // Patched
let name = playerName.value
async function offlineLoginFinally() { // Patched by AstralRinth
const name = playerName.value
if (name.length > 1 && name.length < 20 && name !== '') {
const loggedIn = await offline_login(name).catch(handleError)
loginOfflineModal.value.hide()
@@ -153,43 +176,96 @@ async function offlineLoginFinally() { // Patched
}
}
function retryOfflineLogin() { // Patched
function retryOfflineLogin() { // Patched by AstralRinth
loginErrorModal.value.hide()
tryOfflineLogin()
}
function getAccountType(account) { // Patched
function getAccountType(account) { // Patched by AstralRinth
if (account.access_token != "null" && account.access_token != null && account.access_token != "") {
return License
} else {
return Offline
}
}
const equippedSkin = ref(null)
const headUrlCache = ref(new Map())
async function refreshValues() {
defaultUser.value = await get_default_user().catch(handleError)
accounts.value = await users().catch(handleError)
try {
const skins = await get_available_skins()
equippedSkin.value = skins.find((skin) => skin.is_equipped)
if (equippedSkin.value) {
try {
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
}
} catch {
equippedSkin.value = null
}
}
function setLoginDisabled(value) {
loginDisabled.value = value
}
defineExpose({
refreshValues,
setLoginDisabled,
loginDisabled,
})
await refreshValues()
const displayAccounts = computed(() =>
accounts.value.filter((account) => defaultUser.value !== account.id),
accounts.value.filter((account) => defaultUser.value !== account.profile.id),
)
const avatarUrl = computed(() => {
if (equippedSkin.value?.texture_key) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
}
if (selectedAccount.value?.profile?.id) {
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
}
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
})
function getAccountAvatarUrl(account) {
if (
account.profile.id === selectedAccount.value?.profile?.id &&
equippedSkin.value?.texture_key
) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
}
return `https://mc-heads.net/avatar/${account.profile.id}/128`
}
const selectedAccount = computed(() =>
accounts.value.find((account) => account.id === defaultUser.value),
accounts.value.find((account) => account.profile.id === defaultUser.value),
)
async function setAccount(account) {
defaultUser.value = account.id
await set_default_user(account.id).catch(handleError)
defaultUser.value = account.profile.id
await set_default_user(account.profile.id).catch(handleError)
emit('change')
}
async function login() {
loginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) {
@@ -198,6 +274,7 @@ async function login() {
}
trackEvent('AccountLogIn')
loginDisabled.value = false
}
const logout = async (id) => {

View File

@@ -19,6 +19,7 @@
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query,
}"
class="text-primary"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))

View File

@@ -14,7 +14,7 @@ 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.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'
@@ -92,7 +92,7 @@ async function loginMinecraft() {
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.id).catch(handleError)
await set_default_user(loggedIn.profile.id).catch(handleError)
}
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
@@ -219,8 +219,8 @@ async function copyToClipboard(text) {
<template v-else-if="metadata.notEnoughSpace">
<h3>Not enough space</h3>
<p>
It looks like there is not enough space on the disk containing the dirctory you
selected Please free up some space and try again or cancel the directory change.
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.
</p>
</template>
<template v-else>

View File

@@ -9,7 +9,7 @@ import {
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled } from '@modrinth/ui'
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'
@@ -19,10 +19,8 @@ import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { formatCategory } from '@modrinth/utils'
dayjs.extend(relativeTime)
const formatRelativeTime = useRelativeTime()
const props = defineProps({
instance: {
@@ -173,7 +171,12 @@ onUnmounted(() => unlisten())
</div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon />
<span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
<span class="text-sm">
<template v-if="instance.last_played">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</template>
<template v-else> Never played </template>
</span>
</div>
</div>
</template>
@@ -236,8 +239,8 @@ onUnmounted(() => unlisten())
</p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
<GameIcon class="shrink-0" />
<span class="text-sm">
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
<span class="text-sm capitalize">
{{ instance.loader }} {{ instance.game_version }}
</span>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<ModalWrapper ref="modal" header="Create instance">
<ModalWrapper ref="modal" header="Creating an instance">
<div class="modal-header">
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
</div>

View File

@@ -127,7 +127,7 @@ async function handleJavaFileInput() {
const filePath = await open()
if (filePath) {
let result = await get_jre(filePath.path ?? filePath)
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
if (!result) {
result = {
path: filePath.path ?? filePath,

View File

@@ -70,7 +70,7 @@ const onHide = () => {
v-for="version in filteredVersions"
:key="version.id"
class="table-row with-columns selectable"
@click="$router.push(`/project/${$route.params.id}/version/${version.id}`)"
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<Button

View File

@@ -7,7 +7,7 @@
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),
}"
class="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="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 />
</RouterLink>

View File

@@ -30,7 +30,7 @@ const getInstances = async () => {
return dateB - dateA
})
.slice(0, 4)
.slice(0, 3)
}
await getInstances()

View File

@@ -14,7 +14,10 @@
<div v-if="selectedProcess" class="status">
<span class="circle running" />
<div ref="profileButton" class="running-text">
<router-link :to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`">
<router-link
class="text-primary"
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
>
{{ selectedProcess.profile.name }}
</router-link>
<div v-if="currentProcesses.length > 1" class="arrow button-base" :class="{ rotate: showProfiles }"
@@ -35,7 +38,7 @@
</div>
<div v-if="updateState">
<a>
<Button class="download" :disabled="installState" @click="confirmUpdating(), getRemote(false, false)">
<Button class="download" :disabled="installState" @click="initUpdateModal(), getRemote(false)">
<DownloadIcon />
{{
installState
@@ -45,31 +48,48 @@
</Button>
</a>
</div>
<ModalWrapper ref="confirmUpdate" :has-to-type="false" header="Request to update the AstralRinth launcher">
<ModalWrapper ref="updateModalView" :has-to-type="false" header="Request to update the AstralRinth launcher">
<div class="modal-body">
<div class="markdown-body">
<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>Warning:</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>
<span>Source Git Astralium</span>
<span>Version on remote server <p id="releaseData" class="cosmic inline-fix"></p></span>
<span>Version on local device
<p class="cosmic inline-fix">v{{ version }}</p>
</span>
<div class="button-group push-right">
<Button class="download-modal" @click="confirmUpdate.hide()">
Decline</Button>
<Button class="download-modal" @click="approveUpdate()">
Accept
<Button class="updater-modal" @click="updateModalView.hide()">
Cancel</Button>
<Button class="updater-modal" @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="modal-body">
<div class="markdown-body">
<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 href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">Git Astralium</a> if there are any updates available.</p>
</div>
<span>Local AstralRinth
<p class="cosmic inline-fix">v{{ version }}</p>
</span>
</div>
<div class="button-group push-right">
<Button class="updater-modal" @click="updateRequestFailView.hide()">
Close</Button>
</div>
</ModalWrapper>
</div>
<transition name="download">
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
@@ -125,18 +145,22 @@ const version = await getVersion()
import { installState, getRemote, updateState } from '@/helpers/update.js'
import ModalWrapper from './modal/ModalWrapper.vue'
const confirmUpdate = ref(null)
const updateModalView = ref(null)
const updateRequestFailView = ref(null)
const confirmUpdating = async () => {
confirmUpdate.value.show()
const initUpdateModal = async () => {
updateModalView.value.show()
}
const approveUpdate = async () => {
confirmUpdate.value.hide()
await getRemote(true, true)
const initDownload = async () => {
updateModalView.value.hide()
const result = await getRemote(true);
if (!result) {
updateRequestFailView.value.show()
}
}
await getRemote(true, false)
await getRemote(false)
const router = useRouter()
const card = ref(null)
@@ -371,7 +395,7 @@ onBeforeUnmount(() => {
text-shadow: #26065e;
}
.download-modal {
.updater-modal {
color: #3e8cde;
padding: var(--gap-sm) var(--gap-lg);
text-decoration: none;
@@ -382,9 +406,9 @@ onBeforeUnmount(() => {
transition: color 0.35s ease;
}
.download-modal:hover,
.download-modal:focus,
.download-modal:active {
.updater-modal:hover,
.updater-modal:focus,
.updater-modal:active {
color: #10fae5;
text-shadow: #26065e;
}

View File

@@ -124,8 +124,11 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue'
import { install as installVersion } from '@/store/install.js'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime)
const router = useRouter()
const props = defineProps({
backgroundImage: {
type: String,
@@ -168,6 +171,9 @@ async function install() {
installing.value = false
emit('install', props.project.project_id ?? props.project.id)
},
(profile) => {
router.push(`/instance/${profile}`)
},
)
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui'
import {
UserPlusIcon,
MoreVerticalIcon,
@@ -18,6 +18,8 @@ import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const formatRelativeTime = useRelativeTime()
const props = defineProps<{
credentials: unknown | null
signIn: () => void
@@ -205,7 +207,9 @@ onUnmounted(() => {
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
</template>
</p>
<p class="m-0 text-sm text-secondary">{{ friend.created.fromNow() }}</p>
<p class="m-0 text-sm text-secondary">
{{ formatRelativeTime(friend.created.toISOString()) }}
</p>
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id">

View File

@@ -13,15 +13,17 @@ const confirmModal = ref(null)
const installing = ref(false)
const onInstall = ref(() => {})
const onCreateInstance = ref(() => {})
defineExpose({
show: (projectVal, versionIdVal, callback) => {
show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
project.value = projectVal
versionId.value = versionIdVal
installing.value = false
confirmModal.value.show()
onInstall.value = callback
onCreateInstance.value = createInstanceCallback
trackEvent('PackInstallStart')
},
@@ -36,6 +38,7 @@ async function install() {
versionId.value,
project.value.title,
project.value.icon_url,
onCreateInstance.value,
).catch(handleError)
trackEvent('PackInstall', {
id: project.value.id,

View File

@@ -3,7 +3,7 @@ import { Checkbox } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings'
import { get } from '@/helpers/settings.ts'
import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
@@ -25,9 +25,8 @@ const editProfileObject = computed(() => {
hooks?: Hooks
} = {}
if (overrideHooks.value) {
editProfile.hooks = hooks.value
}
// When hooks are not overridden per-instance, we want to clear them
editProfile.hooks = overrideHooks.value ? hooks.value : {}
return editProfile
})

View File

@@ -7,7 +7,7 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings'
import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
const { formatMessage } = useVIntl()

View File

@@ -3,7 +3,7 @@ import { Checkbox, Toggle } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings'
import { get } from '@/helpers/settings.ts'
import { edit } from '@/helpers/profile'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'

View File

@@ -5,7 +5,7 @@ import {
ShieldIcon,
SettingsIcon,
GaugeIcon,
PaintBrushIcon,
PaintbrushIcon,
GameIcon,
CoffeeIcon,
} from '@modrinth/assets'
@@ -22,7 +22,7 @@ import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/
import { useTheming } from '@/store/state'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get, set } from '@/helpers/settings'
import { get, set } from '@/helpers/settings.ts'
const themeStore = useTheming()
@@ -41,7 +41,7 @@ const tabs = [
id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance',
}),
icon: PaintBrushIcon,
icon: PaintbrushIcon,
content: AppearanceSettings,
},
{

View File

@@ -41,6 +41,10 @@ const props = defineProps({
// type: Boolean,
// default: true,
// },
markdown: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['proceed'])
@@ -79,6 +83,7 @@ function proceed() {
:on-hide="onModalHide"
:noblur="!themeStore.advancedRendering"
:danger="danger"
:markdown="markdown"
@proceed="proceed"
/>
</template>

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useTemplateRef } from 'vue'
import { NewModal as Modal } from '@modrinth/ui'
// import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.js'
@@ -26,15 +26,16 @@ const props = defineProps({
// default: true,
// },
})
const modal = ref(null)
const modal = useTemplateRef('modal')
defineExpose({
show: () => {
modal.value.show()
show: (e: MouseEvent) => {
// hide_ads_window()
modal.value?.show(e)
},
hide: () => {
onModalHide()
modal.value.hide()
modal.value?.hide()
},
})

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings'
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { getOS } from '@/helpers/utils'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming()
@@ -24,13 +25,13 @@ watch(
<ThemeSelector
:update-color-theme="
(theme) => {
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.themeOptions"
:theme-options="themeStore.getThemeOptions()"
system-theme-color="system"
/>
@@ -55,9 +56,17 @@ watch(
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<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>
</div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<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>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
@@ -80,10 +89,28 @@ watch(
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="w-40"
:options="['Home', 'Library']"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<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>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
themeStore.featureFlags['worlds_in_home'] = newValue
settings.feature_flags['worlds_in_home'] = newValue
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { get, set } from '@/helpers/settings'
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications'

View File

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

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings'
import { get, set } from '@/helpers/settings.ts'
import { Toggle } from '@modrinth/ui'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'

View File

@@ -1,7 +1,7 @@
<script setup>
import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.js'
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'

View File

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

View File

@@ -0,0 +1,140 @@
<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 ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const modal = useTemplateRef('modal')
const emit = defineEmits<{
(e: 'select', cape: Cape | undefined): void
(e: 'cancel'): void
}>()
const props = defineProps<{
capes: Cape[]
}>()
const sortedCapes = computed(() => {
return [...props.capes].sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
const currentSkinId = ref<string | undefined>()
const currentSkinTexture = ref<string | undefined>()
const currentSkinVariant = ref<SkinModel>('CLASSIC')
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
const currentCape = ref<Cape | undefined>()
function show(
e: MouseEvent,
skinId?: string,
selected?: Cape,
skinTexture?: string,
variant?: SkinModel,
) {
currentSkinId.value = skinId
currentSkinTexture.value = skinTexture
currentSkinVariant.value = variant || 'CLASSIC'
currentCape.value = selected
modal.value?.show(e)
}
function select() {
emit('select', currentCape.value)
hide()
}
function hide() {
modal.value?.hide()
emit('cancel')
}
function updateSelectedCape(cape: Cape | undefined) {
currentCape.value = cape
}
function onModalHide() {
emit('cancel')
}
defineExpose({
show,
hide,
})
</script>
<template>
<ModalWrapper ref="modal" @on-hide="onModalHide">
<template #title>
<div class="flex flex-col">
<span class="text-lg font-extrabold text-heading">Change cape</span>
</div>
</template>
<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="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
v-if="currentSkinTexture"
:cape-src="currentCapeTexture"
:texture-src="currentSkinTexture"
:variant="currentSkinVariant"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI + Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full my-auto">
<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">
<CapeLikeTextButton
tooltip="No Cape"
:highlighted="!currentCape"
@click="updateSelectedCape(undefined)"
>
<template #icon>
<XIcon />
</template>
<span>None</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in sortedCapes"
:id="cape.id"
:key="cape.id"
:name="cape.name"
:texture="cape.texture"
:selected="currentCape?.id === cape.id"
@select="updateSelectedCape(cape)"
/>
</div>
</ScrollablePanel>
</div>
</div>
<div class="flex gap-2 items-center">
<ButtonStyled color="brand">
<button @click="select">
<CheckIcon />
Select
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import { posthog } from 'posthog-js'
export const initAnalytics = () => {
posthog.init('phc_hm2ihMpTAoE86xIm7XzsCB8RPiTRKivViK5biiHedm', {
posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
persistence: 'localStorage',
api_host: 'https://posthog.modrinth.com',
})
}

View File

@@ -62,7 +62,7 @@ export async function process_listener(callback) {
ProfilePayload {
uuid: unique identification of the process in the state (currently identified by path, but that will change)
name: name of the profile
profile_path: relative path to profile (used for path identification)
profile_path: relative path toprofile_listener profile (used for path identification)
path: path to profile (used for opening the profile in the OS file explorer)
event: event type ("Created", "Added", "Edited", "Removed")
}

View File

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

View File

@@ -7,7 +7,13 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
// Installs pack from a version ID
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
export async function create_profile_and_install(
projectId,
versionId,
packTitle,
iconUrl,
createInstanceCallback = () => {},
) {
const location = {
type: 'fromVersionId',
project_id: projectId,
@@ -24,6 +30,7 @@ export async function create_profile_and_install(projectId, versionId, packTitle
null,
true,
)
createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile })
}

View File

@@ -0,0 +1,354 @@
import * as THREE from 'three'
import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue'
import { setupSkinModel, disposeCaches } from '@modrinth/utils'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
export interface RenderResult {
forwards: string
backwards: string
}
class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer
private readonly scene: THREE.Scene
private readonly camera: THREE.PerspectiveCamera
private currentModel: THREE.Group | null = null
constructor(width: number = 360, height: number = 504) {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
})
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(width, height)
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
}
public async renderSkin(
textureUrl: string,
modelUrl: string,
capeUrl?: string,
capeModelUrl?: string,
): Promise<RenderResult> {
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number]
if (headPart) {
const headPosition = new THREE.Vector3()
headPart.getWorldPosition(headPosition)
lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
} else {
throw new Error("Failed to find 'Head' object in model.")
}
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
const backwards = await this.renderView(backCameraPos, lookAtTarget)
return { forwards, backwards }
}
private async renderView(
cameraPosition: [number, number, number],
lookAtPosition: [number, number, number],
): Promise<string> {
this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera)
return new Promise<string>((resolve, reject) => {
this.renderer.domElement.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob)
resolve(url)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
})
}
private async setupModel(
modelUrl: string,
textureUrl: string,
capeModelUrl?: string,
capeUrl?: string,
): Promise<void> {
if (this.currentModel) {
this.scene.remove(this.currentModel)
}
const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
const group = new THREE.Group()
group.add(model)
group.position.set(0, 0.3, 1.95)
group.scale.set(0.8, 0.8, 0.8)
this.scene.add(group)
this.currentModel = group
}
public dispose(): void {
this.renderer.dispose()
disposeCaches()
}
}
function getModelUrlForVariant(variant: string): string {
switch (variant) {
case 'SLIM':
return SlimPlayerModel
case 'CLASSIC':
case 'UNKNOWN':
default:
return ClassicPlayerModel
}
}
export const map = reactive(new Map<string, RenderResult>())
export const headMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>()
const validHeadKeys = new Set<string>()
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
const headKey = `${skin.texture_key}-head`
validKeys.add(key)
validHeadKeys.add(headKey)
}
try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) {
console.warn('Failed to cleanup unused skin previews:', error)
}
}
export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const sourceCanvas = document.createElement('canvas')
const sourceCtx = sourceCanvas.getContext('2d')
if (!sourceCtx) {
throw new Error('Could not get 2D context from source canvas')
}
sourceCanvas.width = img.width
sourceCanvas.height = img.height
sourceCtx.drawImage(img, 0, 0)
const outputCanvas = document.createElement('canvas')
const outputCtx = outputCanvas.getContext('2d')
if (!outputCtx) {
throw new Error('Could not get 2D context from output canvas')
}
outputCanvas.width = size
outputCanvas.height = size
outputCtx.imageSmoothingEnabled = false
const headImageData = sourceCtx.getImageData(8, 8, 8, 8)
const headCanvas = document.createElement('canvas')
const headCtx = headCanvas.getContext('2d')
if (!headCtx) {
throw new Error('Could not get 2D context from head canvas')
}
headCanvas.width = 8
headCanvas.height = 8
headCtx.putImageData(headImageData, 0, 0)
outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size)
const hatImageData = sourceCtx.getImageData(40, 8, 8, 8)
const hatCanvas = document.createElement('canvas')
const hatCtx = hatCanvas.getContext('2d')
if (!hatCtx) {
throw new Error('Could not get 2D context from hat canvas')
}
hatCanvas.width = 8
hatCanvas.height = 8
hatCtx.putImageData(hatImageData, 0, 0)
const hatPixels = hatImageData.data
let hasHat = false
for (let i = 3; i < hatPixels.length; i += 4) {
if (hatPixels[i] > 0) {
hasHat = true
break
}
}
if (hasHat) {
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
}
outputCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
} catch (error) {
reject(error)
}
}
img.onerror = () => {
reject(new Error('Failed to load skin texture image'))
}
img.src = skinUrl
})
}
async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = `${skin.texture_key}-head`
if (headMap.has(headKey)) {
if (DEBUG_MODE) {
const url = headMap.get(headKey)!
URL.revokeObjectURL(url)
headMap.delete(headKey)
} else {
return headMap.get(headKey)!
}
}
try {
const cached = await skinPreviewStorage.retrieve(headKey)
if (cached && typeof cached === 'string') {
headMap.set(headKey, cached)
return cached
}
} catch (error) {
console.warn('Failed to retrieve cached head render:', error)
}
const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
const headUrl = URL.createObjectURL(headBlob)
headMap.set(headKey, headUrl)
try {
// @ts-expect-error - skinPreviewStorage.store expects a RenderResult, but we are storing a string url.
await skinPreviewStorage.store(headKey, headUrl)
} catch (error) {
console.warn('Failed to store head render in persistent storage:', error)
}
return headUrl
}
export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
return await generateHeadRender(skin)
}
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const renderer = new BatchSkinRenderer()
try {
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (map.has(key)) {
if (DEBUG_MODE) {
const result = map.get(key)!
URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards)
map.delete(key)
} else continue
}
try {
const cached = await skinPreviewStorage.retrieve(key)
if (cached) {
map.set(key, cached)
continue
}
} catch (error) {
console.warn('Failed to retrieve cached skin preview:', error)
}
let variant = skin.variant
if (variant === 'UNKNOWN') {
try {
variant = await determineModelType(skin.texture)
} catch (error) {
console.error(`Failed to determine model type for skin ${key}:`, error)
variant = 'CLASSIC'
}
}
const modelUrl = getModelUrlForVariant(variant)
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
const renderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin),
modelUrl,
cape?.texture,
CapeModel,
)
map.set(key, renderResult)
try {
await skinPreviewStorage.store(key, renderResult)
} catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error)
}
await generateHeadRender(skin)
}
} finally {
renderer.dispose()
await cleanupUnusedPreviews(skins)
}
}

View File

@@ -1,43 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
// Get full settings object
export async function get() {
return await invoke('plugin:settings|settings_get')
}
// Set full settings object
export async function set(settings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change() {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -0,0 +1,79 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
export type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: ColorTheme
default_page: 'home' | 'library'
collapsed_navigation: boolean
hide_nametag_skins_page: boolean
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean
telemetry: boolean
discord_rpc: boolean
personalized_ads: boolean
onboarded: boolean
extra_launch_args: string[]
custom_env_vars: [string, string][]
memory: MemorySettings
force_fullscreen: boolean
game_resolution: WindowSize
hide_on_process_start: boolean
hooks: Hooks
custom_dir?: string | null
prev_custom_dir?: string | null
migrated: boolean
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
}
// Get full settings object
export async function get() {
return (await invoke('plugin:settings|settings_get')) as AppSettings
}
// Set full settings object
export async function set(settings: AppSettings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -0,0 +1,167 @@
import { invoke } from '@tauri-apps/api/core'
import { handleError } from '@/store/notifications'
import { arrayBufferToBase64 } from '@modrinth/utils'
export interface Cape {
id: string
name: string
texture: string
is_default: boolean
is_equipped: boolean
}
export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN'
export type SkinSource = 'default' | 'custom_external' | 'custom'
export interface Skin {
texture_key: string
name?: string
variant: SkinModel
cape_id?: string
texture: string
source: SkinSource
is_equipped: boolean
}
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
export const DEFAULT_MODELS: Record<string, SkinModel> = {
Steve: 'CLASSIC',
Alex: 'SLIM',
Zuri: 'CLASSIC',
Sunny: 'CLASSIC',
Noor: 'SLIM',
Makena: 'SLIM',
Kai: 'CLASSIC',
Efe: 'SLIM',
Ari: 'CLASSIC',
}
export function filterSavedSkins(list: Skin[]) {
const customSkins = list.filter((s) => s.source !== 'default')
fixUnknownSkins(customSkins).catch(handleError)
return customSkins
}
export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) {
return reject(new Error('Failed to create canvas rendering context.'))
}
const image = new Image()
image.crossOrigin = 'anonymous'
image.src = texture
image.onload = () => {
canvas.width = image.width
canvas.height = image.height
context.drawImage(image, 0, 0)
const armX = 44
const armY = 16
const armWidth = 4
const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let y = 0; y < armHeight; y++) {
const alphaIndex = (3 + y * armWidth) * 4 + 3
if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC')
return
}
}
canvas.remove()
resolve('SLIM')
}
image.onerror = () => {
canvas.remove()
reject(new Error('Failed to load the image.'))
}
})
}
export async function fixUnknownSkins(list: Skin[]) {
const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN')
for (const unknownSkin of unknownSkins) {
unknownSkin.variant = await determineModelType(unknownSkin.texture)
}
}
export function filterDefaultSkins(list: Skin[]) {
return list
.filter(
(s) =>
s.source === 'default' &&
(!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
)
.sort((a, b) => {
const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
})
}
export async function get_available_capes(): Promise<Cape[]> {
return invoke('plugin:minecraft-skins|get_available_capes', {})
}
export async function get_available_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_available_skins', {})
}
export async function add_and_equip_custom_skin(
textureBlob: Uint8Array,
variant: SkinModel,
capeOverride?: Cape,
): Promise<void> {
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
textureBlob,
variant,
capeOverride,
})
}
export async function set_default_cape(cape?: Cape): Promise<void> {
await invoke('plugin:minecraft-skins|set_default_cape', {
cape,
})
}
export async function equip_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|equip_skin', {
skin,
})
}
export async function remove_custom_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|remove_custom_skin', {
skin,
})
}
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
const data = await normalize_skin_texture(skin.texture)
const base64 = arrayBufferToBase64(data)
return `data:image/png;base64,${base64}`
}
export async function normalize_skin_texture(texture: Uint8Array | string): Promise<Uint8Array> {
return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
}
export async function unequip_skin(): Promise<void> {
await invoke('plugin:minecraft-skins|unequip_skin')
}
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
return new Uint8Array(data)
}

View File

@@ -0,0 +1,118 @@
import type { RenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview {
forwards: Blob
backwards: Blob
timestamp: number
}
export class SkinPreviewStorage {
private dbName = 'skin-previews'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('previews')) {
db.createObjectStore('previews')
}
}
})
}
async store(key: string, result: RenderResult): Promise<void> {
if (!this.db) await this.init()
const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews')
const storedPreview: StoredPreview = {
forwards: forwardsBlob,
backwards: backwardsBlob,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedPreview, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<RenderResult | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (!result) {
resolve(null)
return
}
const forwards = URL.createObjectURL(result.forwards)
const backwards = URL.createObjectURL(result.backwards)
resolve({ forwards, backwards })
}
request.onerror = () => reject(request.error)
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
}
export const skinPreviewStorage = new SkinPreviewStorage()

View File

@@ -48,6 +48,32 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
hash: string
file_name: string
size: number
metadata?: FileMetadata
update_version_id?: string
project_type: ContentFileProjectType
}
type FileMetadata = {
project_id: string
version_id: string
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
type CacheBehaviour =
// Serve expired data. If fetch fails / launcher is offline, errors are ignored
| 'stale_while_revalidate_skip_offline'
// Serve expired data, revalidate in background
| 'stale_while_revalidate'
// Must revalidate if data is expired
| 'must_revalidate'
// Ignore cache- always fetch updated data from origin
| 'bypass'
type MemorySettings = {
maximum: number
}
@@ -88,6 +114,7 @@ type AppSettings = {
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
worlds_in_home: boolean
telemetry: boolean
discord_rpc: boolean

View File

@@ -2,23 +2,19 @@ import { ref } from 'vue'
import { getVersion } from '@tauri-apps/api/app'
import { getArtifact, getOS } from '@/helpers/utils.js'
export const allowState = ref(false)
export const installState = ref(false)
export const updateState = ref(false)
export const latestBetaCommitTruncatedSha = ref('')
export const latestBetaCommitLink = ref('')
export const launcherUrl = 'https://www.astralium.su/get/ar'
const os = ref('')
const releaseLink = `https://api.github.com/repos/DIDIRUS4/AstralRinth/releases/latest`
const branchesLink = `https://api.github.com/repos/DIDIRUS4/AstralRinth/branches`
const currentOS = ref('')
const releaseLink = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/releases/latest`
const failedFetch = [`Failed to fetch remote releases:`, `Failed to fetch remote commits:`]
const betaBranch = `beta` // Github repository beta branch
const osNames = ['macos', 'windows', 'linux']
const macExtension = `.dmg` // MacOS file type for download
const windowsExtension = `.msi` // Windows file type for download
const blacklistedBuilds = [
const osList = ['macos', 'windows', 'linux']
const macExtensionList = ['.app', '.dmg']
const windowsExtensionList = ['.exe', '.msi']
const blacklistPrefixes = [
`dev`,
`nightly`,
`dirty`,
@@ -28,149 +24,73 @@ const blacklistedBuilds = [
`dirty_nightly`,
] // This is blacklisted builds for download. For example, file.startsWith('dev') is not allowed.
/**
* Asynchronously fetches branches and their latest commit information from the specified URLs.
*
* @return {Promise<void>} This function does not return anything directly but updates the latestBetaCommitTruncatedSha and latestBetaCommitLink values.
*/
export async function getBranches() {
fetch(branchesLink)
.then(async (response) => {
if (response.ok) {
response.json().then((data) => {
const branches = data.map((branch) => branch)
branches.forEach((branch) => {
fetch(branch.commit.url).then(async (data) => {
if (data.ok) {
data.json().then((data) => {
const truncatedSha = data.sha.slice(0, 7)
const commitLink = data.html_url
if (branch.name.toLowerCase() == betaBranch) {
latestBetaCommitTruncatedSha.value = truncatedSha
latestBetaCommitLink.value = commitLink
}
})
} else {
throw new Error(data.status)
}
})
})
})
} else {
throw new Error(response.status)
}
})
.catch((error) => {
latestBetaCommitTruncatedSha.value = error.message
latestBetaCommitLink.value = undefined
console.error(failedFetch[1], error)
})
}
export async function getRemote(isDownloadState) {
var releaseData = null;
var result = false;
try {
const response = await fetch(releaseLink);
if (!response.ok) {
throw new Error(response.status);
}
const remoteData = await response.json();
currentOS.value = await getOS();
const remoteLatestReleaseTag = remoteData.tag_name;
releaseData = document.getElementById('releaseData');
const remoteVersion = releaseData ? (releaseData.textContent = remoteLatestReleaseTag) : remoteLatestReleaseTag;
/**
* Asynchronous function to get remote data and handle updates and downloads.
*
* @param {boolean} elementIdBool - Indicates whether to disable an element ID.
* @param {boolean} downloadArtifactBool - Indicates whether to download an artifact.
*/
export async function getRemote(elementIdBool, downloadArtifactBool) {
fetch(releaseLink)
.then((response) => {
if (!response.ok) {
throw new Error(response.status)
}
return response.json()
})
.then(async (data) => {
os.value = await getOS()
const latestRelease = data.name
let remoteVersion = undefined
if (osList.includes(currentOS.value.toLowerCase())) {
const localVersion = await getVersion();
const isUpdateAvailable = !remoteVersion.includes(localVersion);
if (!elementIdBool) {
const releaseData = document.getElementById('releaseData')
if (releaseData == null) {
console.error('Release data element not found.')
return false
}
releaseData.textContent = latestRelease
remoteVersion = `${releaseData.textContent}`
} else {
remoteVersion = latestRelease
}
if (osNames.includes(os.value.toLowerCase())) {
if (remoteVersion.startsWith('v' + await getVersion())) {
updateState.value = false
allowState.value = false
} else {
updateState.value = true
allowState.value = true
}
} else {
updateState.value = false
allowState.value = false
}
console.log('Update available state is', updateState.value)
console.log('Remote version is', remoteVersion)
console.log('Local version is', await getVersion())
console.log('Operating System is', os.value)
updateState.value = isUpdateAvailable;
allowState.value = isUpdateAvailable;
} else {
updateState.value = false;
allowState.value = false;
}
if (isDownloadState) {
installState.value = true;
const builds = remoteData.assets;
const fileName = getInstaller(getExtension(), builds);
result = fileName ? await getArtifact(fileName[1], fileName[0], currentOS.value, true) : false;
installState.value = false;
}
if (downloadArtifactBool) {
installState.value = true
const builds = data.assets
const fileName = getInstaller(getExtension(), builds)
if (fileName != null) {
await getArtifact(fileName[1], fileName[0], os.value, true)
}
installState.value = false
console.log('Update available state is', updateState.value);
console.log('Remote version is', remoteVersion);
console.log('Local version is', await getVersion());
console.log('Operating System is', currentOS.value);
return result;
} catch (error) {
console.error(failedFetch[0], error);
if (!releaseData) {
const errorData = document.getElementById('releaseData');
if (errorData) {
errorData.textContent = `${error.message}`;
}
})
.catch((error) => {
console.error(failedFetch[0], error)
if (!elementIdBool) {
const errorData = document.getElementById('releaseData')
if (errorData) {
errorData.textContent = `${error.message}`
}
updateState.value = false
allowState.value = false
installState.value = false
}
})
}
/**
* Retrieves the installer for a specific operating system.
*
* @param {string} osExtension - The file extension of the installer.
* @param {Array} builds - The list of builds.
* @return {Array|null} An array containing the installer name and URL if found, or null if not found.
*/
function getInstaller(osExtension, builds) {
for (let i of builds) {
let blacklistedItem = false
blacklistedBuilds.forEach((item) => {
if (i.name.startsWith(item)) {
return (blacklistedItem = true)
}
})
if (i.name.endsWith(osExtension) && !blacklistedItem) {
console.log(i.browser_download_url)
return [i.name, i.browser_download_url]
updateState.value = false;
allowState.value = false;
installState.value = false;
}
}
return null
}
/**
* A function to get the extension based on the operating system.
*
* @return {string} The extension based on the operating system.
*/
function getExtension() {
if (os.value.toLowerCase() == osNames[0]) {
return macExtension
} else if (os.value.toLowerCase() == osNames[1]) {
return windowsExtension
function getInstaller(osExtension, builds) {
console.log(osExtension, builds)
for (const build of builds) {
if (blacklistPrefixes.some(prefix => build.name.startsWith(prefix))) {
continue;
}
if (osExtension.some(ext => build.name.endsWith(ext))) {
console.log(build.name, build.browser_download_url);
return [build.name, build.browser_download_url];
}
}
return null
}
return null;
}
function getExtension() {
return osList.find(osName => osName === currentOS.value.toLowerCase())?.endsWith('macos')
? macExtensionList
: windowsExtensionList;
}

View File

@@ -42,6 +42,13 @@ export async function restartApp() {
return await invoke('restart_app')
}
/**
* @deprecated This method is no longer needed, and just returns its parameter
*/
export function sanitizePotentialFileUrl(url) {
return url
}
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':
@@ -54,3 +61,7 @@ export const releaseColor = (releaseType) => {
return ''
}
}
export async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
}

View File

@@ -0,0 +1,327 @@
import { invoke } from '@tauri-apps/api/core'
import { get_full_path } from '@/helpers/profile'
import { openPath } from '@/helpers/utils'
import { autoToHTML } from '@geometrically/minecraft-motd-parser'
import dayjs from 'dayjs'
import type { GameVersion } from '@modrinth/ui'
type BaseWorld = {
name: string
last_played?: string
icon?: string
display_status: DisplayStatus
type: WorldType
}
export type WorldType = 'singleplayer' | 'server'
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
export type SingleplayerWorld = BaseWorld & {
type: 'singleplayer'
path: string
game_mode: SingleplayerGameMode
hardcore: boolean
locked: boolean
}
export type ServerWorld = BaseWorld & {
type: 'server'
index: number
address: string
pack_status: ServerPackStatus
}
export type World = SingleplayerWorld | ServerWorld
export type WorldWithProfile = {
profile: string
} & World
export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
export type ServerStatus = {
// https://minecraft.wiki/w/Text_component_format
description?: string | Chat
players?: {
max: number
online: number
sample: { name: string; id: string }[]
}
version?: {
name: string
protocol: number
}
favicon?: string
enforces_secure_chat: boolean
ping?: number
}
export interface Chat {
text: string
bold: boolean
italic: boolean
underlined: boolean
strikethrough: boolean
obfuscated: boolean
color?: string
extra: Chat[]
}
export type ServerData = {
refreshing: boolean
status?: ServerStatus
rawMotd?: string | Chat
renderedMotd?: string
}
export async function get_recent_worlds(
limit: number,
displayStatuses?: DisplayStatus[],
): Promise<WorldWithProfile[]> {
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
}
export async function get_profile_worlds(path: string): Promise<World[]> {
return await invoke('plugin:worlds|get_profile_worlds', { path })
}
export async function get_singleplayer_world(
instance: string,
world: string,
): Promise<SingleplayerWorld> {
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
}
export async function set_world_display_status(
instance: string,
worldType: WorldType,
worldId: string,
displayStatus: DisplayStatus,
): Promise<void> {
return await invoke('plugin:worlds|set_world_display_status', {
instance,
worldType,
worldId,
displayStatus,
})
}
export async function rename_world(
instance: string,
world: string,
newName: string,
): Promise<void> {
return await invoke('plugin:worlds|rename_world', { instance, world, newName })
}
export async function reset_world_icon(instance: string, world: string): Promise<void> {
return await invoke('plugin:worlds|reset_world_icon', { instance, world })
}
export async function backup_world(instance: string, world: string): Promise<number> {
return await invoke('plugin:worlds|backup_world', { instance, world })
}
export async function delete_world(instance: string, world: string): Promise<void> {
return await invoke('plugin:worlds|delete_world', { instance, world })
}
export async function add_server_to_profile(
path: string,
name: string,
address: string,
packStatus: ServerPackStatus,
): Promise<number> {
return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
}
export async function edit_server_in_profile(
path: string,
index: number,
name: string,
address: string,
packStatus: ServerPackStatus,
): Promise<void> {
return await invoke('plugin:worlds|edit_server_in_profile', {
path,
index,
name,
address,
packStatus,
})
}
export async function remove_server_from_profile(path: string, index: number): Promise<void> {
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
}
export async function get_profile_protocol_version(path: string): Promise<number | null> {
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
}
export async function get_server_status(
address: string,
protocolVersion: number | null = null,
): Promise<ServerStatus> {
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
}
export async function start_join_singleplayer_world(path: string, world: string): Promise<unknown> {
return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
}
export async function start_join_server(path: string, address: string): Promise<unknown> {
return await invoke('plugin:worlds|start_join_server', { path, address })
}
export async function showWorldInFolder(instancePath: string, worldPath: string) {
const fullPath = await get_full_path(instancePath)
return await openPath(fullPath + '/saves/' + worldPath)
}
export function getWorldIdentifier(world: World) {
return world.type === 'singleplayer' ? world.path : world.address
}
export function sortWorlds(worlds: World[]) {
worlds.sort((a, b) => {
if (!a.last_played) {
return 1
}
if (!b.last_played) {
return -1
}
return dayjs(b.last_played).diff(dayjs(a.last_played))
})
}
export function isSingleplayerWorld(world: World): world is SingleplayerWorld {
return world.type === 'singleplayer'
}
export function isServerWorld(world: World): world is ServerWorld {
return world.type === 'server'
}
export async function refreshServerData(
serverData: ServerData,
protocolVersion: number | null,
address: string,
): Promise<void> {
serverData.refreshing = true
await get_server_status(address, protocolVersion)
.then((status) => {
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
}
})
.catch((err) => {
console.error(`Refreshing addr: ${address}`, err)
})
.finally(() => {
serverData.refreshing = false
})
}
export async function refreshServers(
worlds: World[],
serverData: Record<string, ServerData>,
protocolVersion: number | null,
) {
const servers = worlds.filter(isServerWorld)
servers.forEach((server) => {
if (!serverData[server.address]) {
serverData[server.address] = {
refreshing: true,
}
} else {
serverData[server.address].refreshing = true
}
})
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
Promise.all(
Object.keys(serverData).map((address) =>
refreshServerData(serverData[address], protocolVersion, address),
),
)
}
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
const newWorld = await get_singleplayer_world(instancePath, worldPath)
if (index !== -1) {
worlds[index] = newWorld
} else {
console.info(`Adding new world at path: ${worldPath}.`)
worlds.push(newWorld)
}
sortWorlds(worlds)
}
export async function handleDefaultProfileUpdateEvent(
worlds: World[],
instancePath: string,
e: ProfileEvent,
) {
if (e.event === 'world_updated') {
await refreshWorld(worlds, instancePath, e.world)
}
if (e.event === 'server_joined') {
const world = worlds.find(
(w) =>
w.type === 'server' &&
(w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)),
)
if (world) {
world.last_played = e.timestamp
sortWorlds(worlds)
} else {
console.error(`Could not find world for server join event: ${e.host}:${e.port}`)
}
}
}
export async function refreshWorlds(instancePath: string): Promise<World[]> {
const worlds = await get_profile_worlds(instancePath).catch((err) => {
console.error(`Error refreshing worlds for instance: ${instancePath}`, err)
})
if (worlds) {
sortWorlds(worlds)
}
return worlds ?? []
}
const FIRST_QUICK_PLAY_VERSION = '23w14a'
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return false
}
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
}
export type ProfileEvent = { profile_path_id: string } & (
| {
event: 'servers_updated'
}
| {
event: 'world_updated'
world: string
}
| {
event: 'server_joined'
host: string
port: number
timestamp: string
}
)

View File

@@ -20,12 +20,60 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},
"instance.add-server.add-server": {
"message": "Add server"
},
"instance.add-server.resource-pack.disabled": {
"message": "Disabled"
},
"instance.add-server.resource-pack.enabled": {
"message": "Enabled"
},
"instance.add-server.resource-pack.prompt": {
"message": "Prompt"
},
"instance.add-server.title": {
"message": "Add a server"
},
"instance.edit-server.title": {
"message": "Edit server"
},
"instance.edit-world.hide-from-home": {
"message": "Hide from the Home page"
},
"instance.edit-world.name": {
"message": "Name"
},
"instance.edit-world.placeholder-name": {
"message": "Minecraft World"
},
"instance.edit-world.reset-icon": {
"message": "Reset icon"
},
"instance.edit-world.title": {
"message": "Edit world"
},
"instance.filter.disabled": {
"message": "Disabled projects"
},
"instance.filter.updates-available": {
"message": "Updates available"
},
"instance.server-modal.address": {
"message": "Address"
},
"instance.server-modal.name": {
"message": "Name"
},
"instance.server-modal.placeholder-name": {
"message": "Minecraft Server"
},
"instance.server-modal.resource-pack": {
"message": "Resource pack"
},
"instance.settings.tabs.general": {
"message": "General"
},
@@ -308,6 +356,48 @@
"instance.settings.title": {
"message": "Settings"
},
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
"instance.worlds.cant_connect": {
"message": "Can't connect to server"
},
"instance.worlds.copy_address": {
"message": "Copy address"
},
"instance.worlds.dont_show_on_home": {
"message": "Don't show on Home"
},
"instance.worlds.filter.available": {
"message": "Available"
},
"instance.worlds.game_already_open": {
"message": "Instance is already open"
},
"instance.worlds.hardcore": {
"message": "Hardcore mode"
},
"instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+"
},
"instance.worlds.play_anyway": {
"message": "Play anyway"
},
"instance.worlds.play_instance": {
"message": "Play instance"
},
"instance.worlds.type.server": {
"message": "Server"
},
"instance.worlds.type.singleplayer": {
"message": "Singleplayer"
},
"instance.worlds.view_instance": {
"message": "View instance"
},
"instance.worlds.world_in_use": {
"message": "World is in use"
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},

View File

@@ -6,6 +6,7 @@ import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPlugin } from '@vintl/vintl/plugin'
import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker'
const VIntlPlugin = createPlugin({
controllerOpts: {
@@ -24,6 +25,13 @@ const VIntlPlugin = createPlugin({
injectInto: [],
})
const vueScan = new VueScanPlugin({
enabled: false, // Enable or disable the tracker
showOverlay: true, // Show overlay to visualize renders
log: false, // Log render events to the console
playSound: false, // Play sound on each render
})
const pinia = createPinia()
let app = createApp(App)
@@ -35,6 +43,7 @@ Sentry.init({
tracesSampleRate: 0.1,
})
app.use(vueScan)
app.use(router)
app.use(pinia)
app.use(FloatingVue, {

View File

@@ -220,6 +220,7 @@ async function refreshSearch() {
}
}
results.value = rawResults.result
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
const persistentParams: LocationQuery = {}

View File

@@ -1,4 +1,4 @@
<script setup>
<script setup lang="ts">
import { ref, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue'
@@ -8,19 +8,33 @@ import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js'
const featuredModpacks = ref({})
const featuredMods = ref({})
const filter = ref('')
import type { SearchResult } from '@modrinth/utils'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import type { GameInstance } from '@/helpers/types'
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
const recentInstances = ref([])
const instances = ref<GameInstance[]>([])
const offline = ref(!navigator.onLine)
const featuredModpacks = ref<SearchResult[]>([])
const featuredMods = ref<SearchResult[]>([])
const installedModpacksFilter = ref('')
const recentInstances = computed(() =>
instances.value
.filter((x) => x.last_played)
.slice()
.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))),
)
const hasFeaturedProjects = computed(
() => (featuredModpacks.value?.length ?? 0) + (featuredMods.value?.length ?? 0) > 0,
)
const offline = ref<boolean>(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
@@ -28,34 +42,21 @@ window.addEventListener('online', () => {
offline.value = false
})
const getInstances = async () => {
const profiles = await list().catch(handleError)
recentInstances.value = profiles
.filter((x) => x.last_played)
.sort((a, b) => {
const dateA = dayjs(a.last_played)
const dateB = dayjs(b.last_played)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
async function fetchInstances() {
instances.value = await list().catch(handleError)
const filters = []
for (const instance of profiles) {
for (const instance of instances.value) {
if (instance.linked_data && instance.linked_data.project_id) {
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
}
}
filter.value = filters.join(' AND ')
installedModpacksFilter.value = filters.join(' AND ')
}
const getFeaturedModpacks = async () => {
async function fetchFeaturedModpacks() {
const response = await get_search_results(
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${filter.value}`,
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${installedModpacksFilter.value}`,
)
if (response) {
@@ -64,7 +65,8 @@ const getFeaturedModpacks = async () => {
featuredModpacks.value = []
}
}
const getFeaturedMods = async () => {
async function fetchFeaturedMods() {
const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows')
if (response) {
@@ -74,26 +76,22 @@ const getFeaturedMods = async () => {
}
}
await getInstances()
async function refreshFeaturedProjects() {
await Promise.all([fetchFeaturedModpacks(), fetchFeaturedMods()])
}
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
await fetchInstances()
await refreshFeaturedProjects()
const unlistenProfile = await profile_listener(async (e) => {
await getInstances()
const unlistenProfile = await profile_listener(
async (e: { event: string; profile_path_id: string }) => {
await fetchInstances()
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
}
})
// computed sums of recentInstances, featuredModpacks, featuredMods, treating them as arrays if they are not
const total = computed(() => {
return (
(recentInstances.value?.length ?? 0) +
(featuredModpacks.value?.length ?? 0) +
(featuredMods.value?.length ?? 0)
)
})
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await refreshFeaturedProjects()
}
},
)
onUnmounted(() => {
unlistenProfile()
@@ -102,19 +100,12 @@ onUnmounted(() => {
<template>
<div class="p-6 flex flex-col gap-2">
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl">Welcome to AstralRinth App!</h1>
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to AstralRinth App!</h1>
<RecentWorldsList :recent-instances="recentInstances" />
<RowDisplay
v-if="total > 0"
v-if="hasFeaturedProjects"
:instances="[
{
label: 'Recently played',
route: '/library',
instances: recentInstances,
instance: true,
downloaded: true,
compact: true,
},
{
label: 'Discover a modpack',
route: '/browse/modpack',

View File

@@ -0,0 +1,521 @@
<script setup lang="ts">
import {
EditIcon,
ExcitedRinthbot,
LogInIcon,
PlusIcon,
SpinnerIcon,
TrashIcon,
UpdatedIcon,
} from '@modrinth/assets'
import {
Button,
ButtonStyled,
ConfirmModal,
SkinButton,
SkinLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import { handleError, useNotifications } from '@/store/notifications'
import type { Cape, Skin } from '@/helpers/skins.ts'
import {
normalize_skin_texture,
equip_skin,
filterDefaultSkins,
filterSavedSkins,
get_available_capes,
get_available_skins,
get_normalized_skin_texture,
remove_custom_skin,
set_default_cape,
} from '@/helpers/skins.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { arrayBufferToBase64 } from '@modrinth/utils'
const editSkinModal = useTemplateRef('editSkinModal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const uploadSkinModal = useTemplateRef('uploadSkinModal')
const notifications = useNotifications()
const settings = ref(await getSettings())
const skins = ref<Skin[]>([])
const capes = ref<Cape[]>([])
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
const currentUser = ref(undefined)
const currentUserId = ref<string | undefined>(undefined)
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
const selectedSkin = ref<Skin | null>(null)
const defaultCape = ref<Cape>()
const originalSelectedSkin = ref<Skin | null>(null)
const originalDefaultCape = ref<Cape>()
const savedSkins = computed(() => filterSavedSkins(skins.value))
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
const currentCape = computed(() => {
if (selectedSkin.value?.cape_id) {
const overrideCape = capes.value.find((c) => c.id === selectedSkin.value?.cape_id)
if (overrideCape) {
return overrideCape
}
}
return defaultCape.value
})
const skinTexture = computedAsync(async () => {
if (selectedSkin.value?.texture) {
return await get_normalized_skin_texture(selectedSkin.value)
} else {
return ''
}
})
const capeTexture = computed(() => currentCape.value?.texture)
const skinVariant = computed(() => selectedSkin.value?.variant)
const skinNametag = computed(() =>
settings.value.hide_nametag_skins_page ? undefined : username.value,
)
let userCheckInterval: number | null = null
const deleteSkinModal = ref()
const skinToDelete = ref<Skin | null>(null)
function confirmDeleteSkin(skin: Skin) {
skinToDelete.value = skin
deleteSkinModal.value?.show()
}
async function deleteSkin() {
if (!skinToDelete.value) return
await remove_custom_skin(skinToDelete.value).catch(handleError)
await loadSkins()
skinToDelete.value = null
}
async function loadCapes() {
try {
capes.value = (await get_available_capes()) ?? []
defaultCape.value = capes.value.find((c) => c.is_equipped)
originalDefaultCape.value = defaultCape.value
} catch (error) {
if (currentUser.value) {
handleError(error)
}
}
}
async function loadSkins() {
try {
skins.value = (await get_available_skins()) ?? []
generateSkinPreviews(skins.value, capes.value)
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
originalSelectedSkin.value = selectedSkin.value
} catch (error) {
if (currentUser.value) {
handleError(error)
}
}
}
async function changeSkin(newSkin: Skin) {
const previousSkin = selectedSkin.value
const previousSkinsList = [...skins.value]
skins.value = skins.value.map((skin) => {
return {
...skin,
is_equipped: skin.texture_key === newSkin.texture_key,
}
})
selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null
try {
await equip_skin(newSkin)
if (accountsCard.value) {
await accountsCard.value.refreshValues()
}
} catch (error) {
selectedSkin.value = previousSkin
skins.value = previousSkinsList
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
notifications.addNotification({
type: 'error',
title: 'Slow down!',
text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
})
} else {
handleError(error)
}
}
}
async function handleCapeSelected(cape: Cape | undefined) {
const previousDefaultCape = defaultCape.value
const previousCapesList = [...capes.value]
capes.value = capes.value.map((c) => ({
...c,
is_equipped: cape ? c.id === cape.id : false,
}))
defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined
try {
await set_default_cape(cape)
} catch (error) {
defaultCape.value = previousDefaultCape
capes.value = previousCapesList
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
notifications.addNotification({
type: 'error',
title: 'Slow down!',
text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
})
} else {
handleError(error)
}
}
}
async function onSkinSaved() {
await Promise.all([loadCapes(), loadSkins()])
}
async function loadCurrentUser() {
try {
const defaultId = await get_default_user()
currentUserId.value = defaultId
const allAccounts = await users()
currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId)
} catch (e) {
handleError(e)
currentUser.value = undefined
currentUserId.value = undefined
}
}
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return map.get(key)
}
async function login() {
accountsCard.value.setLoginDisabled(true)
const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn && accountsCard) {
await accountsCard.value.refreshValues()
}
trackEvent('AccountLogIn')
accountsCard.value.setLoginDisabled(false)
}
function openUploadSkinModal(e: MouseEvent) {
uploadSkinModal.value?.show(e)
}
function onSkinFileUploaded(buffer: ArrayBuffer) {
const fakeEvent = new MouseEvent('click')
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
(skinTextureNormalized: Uint8Array) => {
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
} else {
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
}
},
)
}
function onUploadCanceled() {
editSkinModal.value?.restoreModal()
}
watch(
() => selectedSkin.value?.cape_id,
() => {},
)
onMounted(() => {
userCheckInterval = window.setInterval(checkUserChanges, 250)
})
onUnmounted(() => {
if (userCheckInterval !== null) {
window.clearInterval(userCheckInterval)
}
})
async function checkUserChanges() {
try {
const defaultId = await get_default_user()
if (defaultId !== currentUserId.value) {
await loadCurrentUser()
await loadCapes()
await loadSkins()
}
} catch (error) {
if (currentUser.value) {
handleError(error)
}
}
}
await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
</script>
<template>
<EditSkinModal
ref="editSkinModal"
:capes="capes"
:default-cape="defaultCape"
@saved="onSkinSaved"
@deleted="() => loadSkins()"
@open-upload-modal="openUploadSkinModal"
/>
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
<UploadSkinModal
ref="uploadSkinModal"
@uploaded="onSkinFileUploaded"
@canceled="onUploadCanceled"
/>
<ConfirmModal
ref="deleteSkinModal"
title="Are you sure you want to delete this skin?"
description="This will permanently delete the selected skin. This action cannot be undone."
proceed-label="Delete"
@proceed="deleteSkin"
/>
<div v-if="currentUser" class="p-4 skin-layout">
<div class="preview-panel">
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
Skins
<span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
</h1>
<div class="preview-container">
<SkinPreviewRenderer
:cape-src="capeTexture"
:texture-src="skinTexture || ''"
:variant="skinVariant"
:nametag="skinNametag"
:initial-rotation="Math.PI / 8"
>
<template #subtitle>
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
<button
v-tooltip="
selectedSkin?.cape_id
? 'The equipped skin is overriding the default cape.'
: undefined
"
:disabled="!!selectedSkin?.cape_id"
@click="
(e: MouseEvent) =>
selectCapeModal?.show(
e,
selectedSkin?.texture_key,
currentCape,
skinTexture,
skinVariant,
)
"
>
<UpdatedIcon />
Change cape
</button>
</ButtonStyled>
</template>
</SkinPreviewRenderer>
</div>
</div>
<div class="skins-container">
<section class="flex flex-col gap-2 mt-1">
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
<div class="skin-card-grid">
<SkinLikeTextButton class="skin-card" @click="openUploadSkinModal">
<template #icon>
<PlusIcon class="size-8" />
</template>
<span>Add a skin</span>
</SkinLikeTextButton>
<SkinButton
v-for="skin in savedSkins"
:key="`saved-skin-${skin.texture_key}`"
class="skin-card"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
@select="changeSkin(skin)"
>
<template #overlay-buttons>
<Button
color="green"
aria-label="Edit skin"
class="pointer-events-auto"
@click.stop="(e) => editSkinModal?.show(e, skin)"
>
<EditIcon /> Edit
</Button>
<Button
v-show="!skin.is_equipped"
v-tooltip="'Delete skin'"
aria-label="Delete skin"
color="red"
class="!rounded-[100%] pointer-events-auto"
icon-only
@click.stop="() => confirmDeleteSkin(skin)"
>
<TrashIcon />
</Button>
</template>
</SkinButton>
</div>
</section>
<section class="flex flex-col gap-2 mt-6">
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
<div class="skin-card-grid">
<SkinButton
v-for="skin in defaultSkins"
:key="`default-skin-${skin.texture_key}`"
class="skin-card"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
:tooltip="skin.name"
@select="changeSkin(skin)"
/>
</div>
</section>
</div>
</div>
<div v-else class="flex items-center justify-center min-h-[50vh] pt-[25%]">
<div
class="bg-bg-raised rounded-lg p-7 flex flex-col gap-5 shadow-md relative max-w-xl w-full mx-auto"
>
<img
:src="ExcitedRinthbot"
alt="Excited Modrinth Bot"
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
/>
<div
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
style="
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
"
></div>
<div class="flex flex-col gap-5">
<h1 class="text-3xl font-extrabold m-0">Please sign-in</h1>
<p class="text-lg m-0">
Please sign into your Minecraft account to use the skin management features of the
Modrinth app.
</p>
<ButtonStyled v-show="accountsCard" color="brand" :disabled="accountsCard.loginDisabled">
<button :disabled="accountsCard.loginDisabled" @click="login">
<LogInIcon v-if="!accountsCard.loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
Sign In
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
$skin-card-width: 155px;
$skin-card-gap: 4px;
.skin-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
gap: 2.5rem;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.preview-panel {
top: 1.5rem;
position: sticky;
align-self: start;
padding: 0.5rem;
padding-top: 0;
}
.preview-container {
height: 80vh;
display: flex;
align-items: center;
justify-content: center;
margin-left: calc((2.5rem / 2));
@media (max-width: 700px) {
height: 50vh;
}
}
.skins-container {
padding-top: 0.5rem;
}
.skin-card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $skin-card-gap;
width: 100%;
@media (min-width: 1300px) {
grid-template-columns: repeat(4, 1fr);
}
@media (min-width: 1750px) {
grid-template-columns: repeat(5, 1fr);
}
@media (min-width: 2050px) {
grid-template-columns: repeat(6, 1fr);
}
}
.skin-card {
aspect-ratio: 0.95;
border-radius: 10px;
box-sizing: border-box;
width: 100%;
min-width: 0;
}
</style>

View File

@@ -0,0 +1,4 @@
<script setup lang="ts"></script>
<template>
<div class="p-6 flex flex-col gap-2">Worlds</div>
</template>

View File

@@ -1,4 +1,6 @@
import Index from './Index.vue'
import Browse from './Browse.vue'
import Worlds from './Worlds.vue'
import Skins from './Skins.vue'
export { Index, Browse }
export { Index, Browse, Worlds, Skins }

View File

@@ -1,152 +1,160 @@
<template>
<div
class="p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
>
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<ContentPageHeader>
<template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
</template>
<template #title>
{{ instance.name }}
</template>
<template #summary> </template>
<template #stats>
<div
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
>
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="flex items-center gap-2 font-semibold">
<TimerIcon class="h-6 w-6 text-secondary" />
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
</template>
<template #actions>
<div class="flex gap-2">
<ButtonStyled
v-if="instance.install_stage.includes('installing')"
color="brand"
size="large"
>
<button disabled>Installing...</button>
</ButtonStyled>
<ButtonStyled
v-else-if="instance.install_stage !== 'installed'"
color="brand"
size="large"
>
<button @click="repairInstance()">
<DownloadIcon />
Repair
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false"
color="brand"
size="large"
>
<button @click="startInstance('InstancePage')">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="loading === true && playing === false"
color="brand"
size="large"
>
<button disabled>Loading...</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
},
{
id: 'export-mrpack',
action: () => $refs.exportModal.show(),
},
]"
>
<MoreVerticalIcon />
<template #share-instance> <UserPlusIcon /> Share instance </template>
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
</ContentPageHeader>
</div>
<div class="px-6">
<NavTabs :links="tabs" />
</div>
<div class="p-6 pt-4">
<RouterView v-slot="{ Component }" :key="instance.path">
<template v-if="Component">
<Suspense
:key="instance.path"
@pending="loadingBar.startLoading()"
@resolve="loadingBar.stopLoading()"
>
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
></component>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_names><EditIcon />Copy names</template>
<template #copy_slugs><HashIcon />Copy slugs</template>
<template #copy_links><GlobeIcon />Copy links</template>
<template #toggle><EditIcon />Toggle selected</template>
<template #disable><XIcon />Disable selected</template>
<template #enable><CheckCircleIcon />Enable selected</template>
<template #hide_show><EyeIcon />Show/Hide unselected</template>
<template #update_all
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
<div>
<div
class="p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
>
<template #filter_update><UpdatedIcon />Select Updatable</template>
</ContextMenu>
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<ContentPageHeader>
<template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
</template>
<template #title>
{{ instance.name }}
</template>
<template #summary> </template>
<template #stats>
<div
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
>
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="flex items-center gap-2 font-semibold">
<TimerIcon class="h-6 w-6 text-secondary" />
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
</template>
<template #actions>
<div class="flex gap-2">
<ButtonStyled
v-if="
['installing', 'pack_installing', 'minecraft_installing'].includes(
instance.install_stage,
)
"
color="brand"
size="large"
>
<button disabled>Installing...</button>
</ButtonStyled>
<ButtonStyled
v-else-if="instance.install_stage !== 'installed'"
color="brand"
size="large"
>
<button @click="repairInstance()">
<DownloadIcon />
Repair
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false"
color="brand"
size="large"
>
<button @click="startInstance('InstancePage')">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="loading === true && playing === false"
color="brand"
size="large"
>
<button disabled>Loading...</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
},
{
id: 'export-mrpack',
action: () => $refs.exportModal.show(),
},
]"
>
<MoreVerticalIcon />
<template #share-instance> <UserPlusIcon /> Share instance </template>
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
</ContentPageHeader>
</div>
<div class="px-6">
<NavTabs :links="tabs" />
</div>
<div v-if="!!instance" class="p-6 pt-4">
<RouterView v-slot="{ Component }" :key="instance.path">
<template v-if="Component">
<Suspense
:key="instance.path"
@pending="loadingBar.startLoading()"
@resolve="loadingBar.stopLoading()"
>
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
</div>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_names><EditIcon />Copy names</template>
<template #copy_slugs><HashIcon />Copy slugs</template>
<template #copy_links><GlobeIcon />Copy links</template>
<template #toggle><EditIcon />Toggle selected</template>
<template #disable><XIcon />Disable selected</template>
<template #enable><CheckCircleIcon />Enable selected</template>
<template #hide_show><EyeIcon />Show/Hide unselected</template>
<template #update_all
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
>
<template #filter_update><UpdatedIcon />Select Updatable</template>
</ContextMenu>
</div>
</template>
<script setup>
import {
@@ -238,6 +246,10 @@ async function fetchInstance() {
})
}
await updatePlayState()
}
async function updatePlayState() {
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
playing.value = runningProcesses.length > 0
@@ -253,14 +265,20 @@ watch(
},
)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
const tabs = computed(() => [
{
label: 'Content',
href: `/instance/${encodeURIComponent(route.params.id)}`,
href: `${basePath.value}`,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
},
{
label: 'Logs',
href: `/instance/${encodeURIComponent(route.params.id)}/logs`,
href: `${basePath.value}/logs`,
},
])

View File

@@ -117,15 +117,37 @@ const route = useRoute()
const props = defineProps({
instance: {
type: Object,
required: true,
default() {
return {}
},
},
options: {
type: Object,
default() {
return {}
},
},
offline: {
type: Boolean,
default: false,
default() {
return false
},
},
playing: {
type: Boolean,
default: false,
default() {
return false
},
},
versions: {
type: Array,
required: true,
},
installed: {
type: Boolean,
default() {
return false
},
},
})

View File

@@ -1,251 +1,252 @@
<template>
<template v-if="projects?.length > 0">
<div class="flex items-center gap-2 mb-4">
<div class="iconified-input flex-grow">
<SearchIcon />
<input
v-model="searchFilter"
type="text"
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
class="text-input search-input"
autocomplete="off"
<div>
<template v-if="projects?.length > 0">
<div class="flex items-center gap-2 mb-4">
<div class="iconified-input flex-grow">
<SearchIcon />
<input
v-model="searchFilter"
type="text"
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
class="text-input search-input"
autocomplete="off"
/>
<Button class="r-btn" @click="() => (searchFilter = '')">
<XIcon />
</Button>
</div>
<AddContentButton :instance="instance" />
</div>
<div class="flex items-center justify-between">
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button
v-for="filter in filterOptions"
:key="`content-filter-${filter.id}`"
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
@click="toggleArray(selectedFilters, filter.id)"
>
{{ filter.formattedName }}
</button>
</div>
<Pagination
v-if="search.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
:link-function="(page) => `?page=${page}`"
@switch-page="(page) => (currentPage = page)"
/>
<Button class="r-btn" @click="() => (searchFilter = '')">
<XIcon />
</Button>
</div>
<AddContentButton :instance="instance" />
</div>
<div class="flex items-center justify-between">
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button
v-for="filter in filterOptions"
:key="filter"
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
@click="toggleArray(selectedFilters, filter.id)"
>
{{ filter.formattedName }}
</button>
</div>
<Pagination
v-if="search.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
:link-function="(page) => `?page=${page}`"
@switch-page="(page) => (currentPage = page)"
/>
</div>
<ContentListPanel
v-model="selectedFiles"
:locked="isPackLocked"
:items="
search.map((x) => {
const item: ContentItem<any> = {
path: x.path,
disabled: x.disabled,
filename: x.file_name,
icon: x.icon,
title: x.name,
data: x,
}
if (x.version) {
item.version = x.version
item.versionId = x.version
}
if (x.id) {
item.project = {
id: x.id,
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
linkProps: {},
<ContentListPanel
v-model="selectedFiles"
:locked="isPackLocked"
:items="
search.map((x) => {
const item: ContentItem<any> = {
path: x.path,
disabled: x.disabled,
filename: x.file_name,
icon: x.icon ?? undefined,
title: x.name,
data: x,
}
}
if (x.author) {
item.creator = {
name: x.author,
type: 'user',
id: x.author,
link: 'https://modrinth.com/user/' + x.author,
linkProps: { target: '_blank' },
if (x.version) {
item.version = x.version
item.versionId = x.version
}
}
return item
})
"
:sort-column="sortColumn"
:sort-ascending="ascending"
:update-sort="sortProjects"
:current-page="currentPage"
>
<template v-if="selectedProjects.length > 0" #headers>
<div class="flex gap-2">
if (x.id) {
item.project = {
id: x.id,
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
linkProps: {},
}
}
if (x.author) {
item.creator = {
name: x.author.name,
type: x.author.type,
id: x.author.slug,
link: `https://modrinth.com/${x.author.type}/${x.author.slug}`,
linkProps: { target: '_blank' },
}
}
return item
})
"
:sort-column="sortColumn"
:sort-ascending="ascending"
:update-sort="sortProjects"
:current-page="currentPage"
>
<template v-if="selectedProjects.length > 0" #headers>
<div class="flex gap-2">
<ButtonStyled
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
color="brand"
color-fill="text"
hover-color-fill="text"
>
<button @click="updateSelected()"><DownloadIcon /> Update</button>
</ButtonStyled>
<ButtonStyled>
<OverflowMenu
:options="[
{
id: 'share-names',
action: () => shareNames(),
},
{
id: 'share-file-names',
action: () => shareFileNames(),
},
{
id: 'share-urls',
action: () => shareUrls(),
},
{
id: 'share-markdown',
action: () => shareMarkdown(),
},
]"
>
<ShareIcon /> Share <DropdownIcon />
<template #share-names> <TextInputIcon /> Project names </template>
<template #share-file-names> <FileIcon /> File names </template>
<template #share-urls> <LinkIcon /> Project links </template>
<template #share-markdown> <CodeIcon /> Markdown links </template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
</ButtonStyled>
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
<button @click="disableAll()"><SlashIcon /> Disable</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
</ButtonStyled>
</div>
</template>
<template #header-actions>
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
<UpdatedIcon />
Refresh
</button>
</ButtonStyled>
<ButtonStyled
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
type="transparent"
color="brand"
color-fill="text"
hover-color-fill="text"
@click="updateAll"
>
<button class="w-max"><DownloadIcon /> Update all</button>
</ButtonStyled>
<ButtonStyled
v-if="canUpdatePack"
type="transparent"
color="brand"
color-fill="text"
hover-color-fill="text"
>
<button @click="updateSelected()"><DownloadIcon /> Update</button>
<button class="w-max" :disabled="installing" @click="modpackVersionModal?.show()">
<DownloadIcon /> Update pack
</button>
</ButtonStyled>
<ButtonStyled>
</template>
<template #actions="{ item }">
<ButtonStyled
v-if="!isPackLocked && (item.data as any).outdated"
type="transparent"
color="brand"
circular
>
<button
v-tooltip="`Update`"
:disabled="(item.data as ProjectListEntry).updating"
@click="updateProject(item.data)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<div v-else class="w-[36px]"></div>
<Toggle
class="!mx-2"
:model-value="!item.data.disabled"
@update:model-value="toggleDisableMod(item.data)"
/>
<ButtonStyled type="transparent" circular>
<button v-tooltip="'Remove'" @click="removeMod(item)">
<TrashIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'share-names',
action: () => shareNames(),
id: 'show-file',
action: () => highlightModInProfile(instance.path, item.path),
},
{
id: 'share-file-names',
action: () => shareFileNames(),
},
{
id: 'share-urls',
action: () => shareUrls(),
},
{
id: 'share-markdown',
action: () => shareMarkdown(),
id: 'copy-link',
shown: item.data !== undefined && item.data.slug !== undefined,
action: () => copyModLink(item),
},
]"
direction="left"
>
<ShareIcon /> Share <DropdownIcon />
<template #share-names> <TextInputIcon /> Project names </template>
<template #share-file-names> <FileIcon /> File names </template>
<template #share-urls> <LinkIcon /> Project links </template>
<template #share-markdown> <CodeIcon /> Markdown links </template>
<MoreVerticalIcon />
<template #show-file> <ExternalIcon /> Show file </template>
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
</ButtonStyled>
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
<button @click="disableAll()"><SlashIcon /> Disable</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
</ButtonStyled>
</div>
</template>
<template #header-actions>
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
<UpdatedIcon />
Refresh
</button>
</ButtonStyled>
<ButtonStyled
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
type="transparent"
color="brand"
color-fill="text"
hover-color-fill="text"
@click="updateAll"
>
<button class="w-max"><DownloadIcon /> Update all</button>
</ButtonStyled>
<ButtonStyled
v-if="canUpdatePack"
type="transparent"
color="brand"
color-fill="text"
hover-color-fill="text"
>
<button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
<DownloadIcon /> Update pack
</button>
</ButtonStyled>
</template>
<template #actions="{ item }">
<ButtonStyled
v-if="!isPackLocked && (item.data as any).outdated"
type="transparent"
color="brand"
circular
>
<button
v-tooltip="`Update`"
:disabled="(item.data as any).updating"
@click="updateProject(item.data)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<div v-else class="w-[36px]"></div>
<Toggle
class="!mx-2"
:model-value="!item.data.disabled"
@update:model-value="toggleDisableMod(item.data)"
</template>
</ContentListPanel>
<div class="flex justify-end mt-4">
<Pagination
v-if="search.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
:link-function="(page) => `?page=${page}`"
@switch-page="(page) => (currentPage = page)"
/>
<ButtonStyled type="transparent" circular>
<button v-tooltip="'Remove'" @click="removeMod(item)">
<TrashIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'show-file',
action: () => highlightModInProfile(instance.path, item.path),
},
{
id: 'copy-link',
shown: item.data !== undefined && item.data.slug !== undefined,
action: () => copyModLink(item),
},
]"
direction="left"
</div>
</template>
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
<RadialHeader class="">
<div class="flex items-center gap-6 w-[32rem] mx-auto">
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
<span class="text-contrast font-bold text-xl"
>You haven't added any content to this instance yet.</span
>
<MoreVerticalIcon />
<template #show-file> <ExternalIcon /> Show file </template>
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
</OverflowMenu>
</ButtonStyled>
</template>
</ContentListPanel>
<div class="flex justify-end mt-4">
<Pagination
v-if="search.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
:link-function="(page) => `?page=${page}`"
@switch-page="(page) => (currentPage = page)"
/>
</div>
</template>
<div v-else class="w-full flex flex-col items-center justify-center mt-6 max-w-[48rem] mx-auto">
<div class="top-box w-full">
<div class="flex items-center gap-6 w-[32rem] mx-auto">
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
<span class="text-contrast font-bold text-xl"
>You haven't added any content to this instance yet.</span
>
</div>
</RadialHeader>
<div class="flex mt-4 mx-auto">
<AddContentButton :instance="instance" />
</div>
</div>
<div class="top-box-divider"></div>
<div class="flex items-center gap-6 py-4">
<AddContentButton :instance="instance" />
</div>
<ShareModalWrapper
ref="shareModal"
share-title="Sharing modpack content"
share-text="Check out the projects I'm using in my modpack!"
:open-in-new-tab="false"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ModpackVersionModal
v-if="instance.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
</div>
<ShareModalWrapper
ref="shareModal"
share-title="Sharing modpack content"
share-text="Check out the projects I'm using in my modpack!"
:open-in-new-tab="false"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ModpackVersionModal
v-if="instance.linked_data"
ref="modpackVersionModal"
:instance="instance"
:versions="props.versions"
/>
</template>
<script setup lang="ts">
import {
@@ -272,8 +273,10 @@ import {
ContentListPanel,
OverflowMenu,
Pagination,
RadialHeader,
Toggle,
} from '@modrinth/ui'
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { formatProjectType } from '@modrinth/utils'
import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
@@ -303,31 +306,42 @@ import { profile_listener } from '@/helpers/events.js'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import dayjs from 'dayjs'
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
const props = defineProps({
instance: {
type: Object,
default() {
return {}
},
},
options: {
type: Object,
default() {
return {}
},
},
offline: {
type: Boolean,
default() {
return false
},
},
versions: {
type: Array,
required: true,
},
})
const props = defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu>
offline: boolean
playing: boolean
versions: Version[]
installed: boolean
}>()
type ProjectListEntryAuthor = {
name: string
slug: string
type: 'user' | 'organization'
}
type ProjectListEntry = {
path: string
name: string
slug?: string
author: ProjectListEntryAuthor | null
version: string | null
file_name: string
icon: string | undefined
disabled: boolean
updateVersion?: string
outdated: boolean
updated: dayjs.Dayjs
project_type: string
id?: string
updating?: boolean
selected?: boolean
}
const isPackLocked = computed(() => {
return props.instance.linked_data && props.instance.linked_data.locked
@@ -338,18 +352,21 @@ const canUpdatePack = computed(() => {
})
const exportModal = ref(null)
const projects = ref([])
const selectedFiles = ref([])
const projects = ref<ProjectListEntry[]>([])
const selectedFiles = ref<string[]>([])
const selectedProjects = computed(() =>
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
)
const selectionMap = ref(new Map())
const initProjects = async (cacheBehaviour?) => {
const newProjects = []
const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
const newProjects: ProjectListEntry[] = []
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
const profileProjects = (await get_projects(props.instance.path, cacheBehaviour)) as Record<
string,
ContentFile
>
const fetchProjects = []
const fetchVersions = []
@@ -361,21 +378,21 @@ const initProjects = async (cacheBehaviour?) => {
}
const [modrinthProjects, modrinthVersions] = await Promise.all([
await get_project_many(fetchProjects).catch(handleError),
await get_version_many(fetchVersions).catch(handleError),
(await get_project_many(fetchProjects).catch(handleError)) as Project[],
(await get_version_many(fetchVersions).catch(handleError)) as Version[],
])
const [modrinthTeams, modrinthOrganizations] = await Promise.all([
await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError),
await get_organization_many(
(await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError)) as TeamMember[][],
(await get_organization_many(
modrinthProjects.map((x) => x.organization).filter((x) => !!x),
).catch(handleError),
).catch(handleError)) as Organization[],
])
for (const [path, file] of Object.entries(profileProjects)) {
if (file.metadata) {
const project = modrinthProjects.find((x) => file.metadata.project_id === x.id)
const version = modrinthVersions.find((x) => file.metadata.version_id === x.id)
const project = modrinthProjects.find((x) => file.metadata?.project_id === x.id)
const version = modrinthVersions.find((x) => file.metadata?.version_id === x.id)
if (project && version) {
const org = project.organization
@@ -384,21 +401,29 @@ const initProjects = async (cacheBehaviour?) => {
const team = modrinthTeams.find((x) => x[0].team_id === project.team)
let owner
let author: ProjectListEntryAuthor | null = null
if (org) {
owner = org.name
author = {
name: org.name,
slug: org.slug,
type: 'organization',
}
} else if (team) {
owner = team.find((x) => x.is_owner).user.username
} else {
owner = null
const teamMember = team.find((x) => x.is_owner)
if (teamMember) {
author = {
name: teamMember.user.username,
slug: teamMember.user.username,
type: 'user',
}
}
}
newProjects.push({
path,
name: project.title,
slug: project.slug,
author: owner,
author,
version: version.version_number,
file_name: file.file_name,
icon: project.icon_url,
@@ -417,10 +442,10 @@ const initProjects = async (cacheBehaviour?) => {
newProjects.push({
path,
name: file.file_name.replace('.disabled', ''),
author: '',
author: null,
version: null,
file_name: file.file_name,
icon: null,
icon: undefined,
disabled: file.file_name.endsWith('.disabled'),
outdated: false,
updated: dayjs(0),
@@ -428,7 +453,7 @@ const initProjects = async (cacheBehaviour?) => {
})
}
projects.value = newProjects
projects.value = newProjects ?? []
const newSelectionMap = new Map()
for (const project of projects.value) {
@@ -444,7 +469,7 @@ const initProjects = async (cacheBehaviour?) => {
}
await initProjects()
const modpackVersionModal = ref(null)
const modpackVersionModal = ref<InstanceType<typeof ModpackVersionModal> | null>()
const installing = computed(() => props.instance.install_stage !== 'installed')
const vintl = useVIntl()
@@ -469,7 +494,7 @@ const messages = defineMessages({
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
const options: FilterOption[] = []
const frequency = projects.value.reduce((map, item) => {
const frequency = projects.value.reduce((map: Record<string, number>, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1
return map
}, {})
@@ -500,7 +525,7 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
return options
})
const selectedFilters = ref([])
const selectedFilters = ref<string[]>([])
const filteredProjects = computed(() => {
const updatesFilter = selectedFilters.value.includes('updates')
const disabledFilter = selectedFilters.value.includes('disabled')
@@ -527,7 +552,7 @@ watch(filterOptions, () => {
}
})
function toggleArray(array, value) {
function toggleArray<T>(array: T[], value: T) {
if (array.includes(value)) {
array.splice(array.indexOf(value), 1)
} else {
@@ -537,7 +562,7 @@ function toggleArray(array, value) {
const searchFilter = ref('')
const selectAll = ref(false)
const shareModal = ref(null)
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
const ascending = ref(true)
const sortColumn = ref('Name')
const currentPage = ref(1)
@@ -578,7 +603,7 @@ const search = computed(() => {
watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1))
const sortProjects = (filter) => {
const sortProjects = (filter: string) => {
if (sortColumn.value === filter) {
ascending.value = !ascending.value
} else {
@@ -596,7 +621,7 @@ const updateAll = async () => {
}
}
const paths = await update_all(props.instance.path).catch(handleError)
const paths = (await update_all(props.instance.path).catch(handleError)) as Record<string, string>
for (const [oldVal, newVal] of Object.entries(paths)) {
const index = projects.value.findIndex((x) => x.path === oldVal)
@@ -605,7 +630,7 @@ const updateAll = async () => {
if (projects.value[index].updateVersion) {
projects.value[index].version = projects.value[index].updateVersion.version_number
projects.value[index].updateVersion = null
projects.value[index].updateVersion = undefined
}
}
for (const project of setProjects) {
@@ -620,15 +645,15 @@ const updateAll = async () => {
})
}
const updateProject = async (mod) => {
const updateProject = async (mod: ProjectListEntry) => {
mod.updating = true
await new Promise((resolve) => setTimeout(resolve, 0))
mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
mod.updating = false
mod.outdated = false
mod.version = mod.updateVersion.version_number
mod.updateVersion = null
mod.version = mod.updateVersion?.version_number
mod.updateVersion = undefined
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
@@ -639,15 +664,15 @@ const updateProject = async (mod) => {
})
}
const locks = {}
const locks: Record<string, string | null> = {}
const toggleDisableMod = async (mod) => {
const toggleDisableMod = async (mod: ProjectListEntry) => {
// Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property.
const lock = locks[mod.file_name]
while (lock) {
await new Promise((resolve) => {
setTimeout((_) => resolve(), 100)
setTimeout((value: unknown) => resolve(value), 100)
})
}
@@ -672,20 +697,20 @@ const toggleDisableMod = async (mod) => {
locks[mod.file_name] = null
}
const removeMod = async (mod) => {
const removeMod = async (mod: ContentItem<ProjectListEntry>) => {
await remove_project(props.instance.path, mod.path).catch(handleError)
projects.value = projects.value.filter((x) => mod.path !== x.path)
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.id,
name: mod.name,
project_type: mod.project_type,
id: mod.data.id,
name: mod.data.name,
project_type: mod.data.project_type,
})
}
const copyModLink = async (mod) => {
const copyModLink = async (mod: ContentItem<ProjectListEntry>) => {
await navigator.clipboard.writeText(
`https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`,
)
@@ -700,15 +725,15 @@ const deleteSelected = async () => {
}
const shareNames = async () => {
await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n'))
await shareModal.value?.show(functionValues.value.map((x) => x.name).join('\n'))
}
const shareFileNames = async () => {
await shareModal.value.show(functionValues.value.map((x) => x.file_name).join('\n'))
await shareModal.value?.show(functionValues.value.map((x) => x.file_name).join('\n'))
}
const shareUrls = async () => {
await shareModal.value.show(
await shareModal.value?.show(
functionValues.value
.filter((x) => x.slug)
.map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`)
@@ -717,7 +742,7 @@ const shareUrls = async () => {
}
const shareMarkdown = async () => {
await shareModal.value.show(
await shareModal.value?.show(
functionValues.value
.map((x) => {
if (x.slug) {
@@ -782,15 +807,17 @@ const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
await initProjects()
})
const unlistenProfiles = await profile_listener(async (event) => {
if (
event.profile_path_id === props.instance.path &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing'
) {
await initProjects()
}
})
const unlistenProfiles = await profile_listener(
async (event: { event: string; profile_path_id: string }) => {
if (
event.profile_path_id === props.instance.path &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing'
) {
await initProjects()
}
},
)
onUnmounted(() => {
unlisten()

View File

@@ -0,0 +1,15 @@
<template>{{ instance.name }} overview</template>
<script setup lang="ts">
import type { GameInstance } from '@/helpers/types'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Version } from '@modrinth/utils'
defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu>
offline: boolean
playing: boolean
versions: Version[]
installed: boolean
}>()
</script>

View File

@@ -0,0 +1,465 @@
<template>
<AddServerModal
ref="addServerModal"
:instance="instance"
@submit="
(server, start) => {
addServer(server)
if (start) {
joinWorld(server)
}
}
"
/>
<EditServerModal ref="editServerModal" :instance="instance" @submit="editServer" />
<EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" />
<ConfirmModalWrapper
ref="removeServerModal"
:title="`Are you sure you want to remove ${serverToRemove?.name ?? 'this server'}?`"
:description="`'${serverToRemove?.name}'${serverToRemove?.address === serverToRemove?.name ? ' ' : ` (${serverToRemove?.address})`} will be removed from your list, including in-game, and there will be no way to recover it.`"
:markdown="false"
@proceed="proceedRemoveServer"
/>
<ConfirmModalWrapper
ref="deleteWorldModal"
:title="`Are you sure you want to permanently delete this world?`"
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`"
@proceed="proceedDeleteWorld"
/>
<div v-if="worlds.length > 0" class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2 items-center">
<div class="iconified-input flex-grow">
<SearchIcon />
<input
v-model="searchFilter"
type="text"
:placeholder="`Search worlds...`"
class="text-input search-input"
autocomplete="off"
/>
<Button v-if="searchFilter" class="r-btn" @click="() => (searchFilter = '')">
<XIcon />
</Button>
</div>
<ButtonStyled>
<button :disabled="refreshingAll" @click="refreshAllWorlds">
<template v-if="refreshingAll">
<SpinnerIcon class="animate-spin" />
Refreshing...
</template>
<template v-else>
<UpdatedIcon />
Refresh
</template>
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="addServerModal?.show()">
<PlusIcon />
Add a server
</button>
</ButtonStyled>
</div>
<FilterBar v-model="filters" :options="filterOptions" show-all-options />
<div class="flex flex-col w-full gap-2">
<WorldItem
v-for="world in filteredWorlds"
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world"
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-quick-play="supportsQuickPlay"
:current-protocol="protocolVersion"
:playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)"
:starting-instance="startingInstance"
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
:rendered-motd="
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
"
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
@play="() => joinWorld(world)"
@stop="() => emit('stop')"
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
"
@delete="() => promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/>
</div>
</div>
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
<RadialHeader class="">
<div class="flex items-center gap-6 w-[32rem] mx-auto">
<img src="@/assets/sad-modrinth-bot.webp" alt="" aria-hidden="true" class="h-24" />
<span class="text-contrast font-bold text-xl"> You don't have any worlds yet. </span>
</div>
</RadialHeader>
<div class="flex gap-2 mt-4 mx-auto">
<ButtonStyled>
<button @click="addServerModal?.show()">
<PlusIcon aria-hidden="true" />
Add a server
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="refreshingAll" @click="refreshAllWorlds">
<template v-if="refreshingAll">
<SpinnerIcon aria-hidden="true" class="animate-spin" />
Refreshing...
</template>
<template v-else>
<UpdatedIcon aria-hidden="true" />
Refresh
</template>
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import {
Button,
ButtonStyled,
RadialHeader,
FilterBar,
type FilterBarOption,
type GameVersion,
GAME_MODES,
} from '@modrinth/ui'
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
import {
type SingleplayerWorld,
type World,
type ServerWorld,
type ServerData,
type ProfileEvent,
get_profile_protocol_version,
remove_server_from_profile,
delete_world,
start_join_server,
start_join_singleplayer_world,
getWorldIdentifier,
refreshServerData,
refreshWorld,
sortWorlds,
refreshServers,
hasQuickPlaySupport,
refreshWorlds,
handleDefaultProfileUpdateEvent,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { handleError } from '@/store/notifications'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Version } from '@modrinth/utils'
import { profile_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags'
import { defineMessages } from '@vintl/vintl'
const route = useRoute()
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
const editWorldModal = ref<InstanceType<typeof EditWorldModal>>()
const removeServerModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
const deleteWorldModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
const serverToRemove = ref<ServerWorld>()
const worldToDelete = ref<SingleplayerWorld>()
const emit = defineEmits<{
(event: 'play', world: World): void
(event: 'stop'): void
}>()
const props = defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu> | null
offline: boolean
playing: boolean
versions: Version[]
installed: boolean
}>()
const instance = computed(() => props.instance)
const playing = computed(() => props.playing)
function play(world: World) {
emit('play', world)
}
const filters = ref<string[]>([])
const searchFilter = ref('')
const refreshingAll = ref(false)
const hadNoWorlds = ref(true)
const startingInstance = ref(false)
const worldPlaying = ref<World>()
const worlds = ref<World[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
if (e.event === 'servers_updated') {
await refreshAllWorlds()
}
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
})
await refreshAllWorlds()
async function refreshServer(address: string) {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
}
}
await refreshServerData(serverData.value[address], protocolVersion.value, address)
}
async function refreshAllWorlds() {
if (refreshingAll.value) {
console.log(`Already refreshing, cancelling refresh.`)
return
}
refreshingAll.value = true
worlds.value = await refreshWorlds(instance.value.path).finally(
() => (refreshingAll.value = false),
)
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
const hasNoWorlds = worlds.value.length === 0
if (hadNoWorlds.value && hasNoWorlds) {
setTimeout(() => {
refreshingAll.value = false
}, 1000)
} else {
refreshingAll.value = false
}
hadNoWorlds.value = hasNoWorlds
}
async function addServer(server: ServerWorld) {
worlds.value.push(server)
sortWorlds(worlds.value)
await refreshServer(server.address)
}
async function editServer(server: ServerWorld) {
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
if (index !== -1) {
const oldServer = worlds.value[index] as ServerWorld
worlds.value[index] = server
sortWorlds(worlds.value)
if (oldServer.address !== server.address) {
await refreshServer(server.address)
}
} else {
handleError(`Error refreshing server, refreshing all worlds`)
await refreshAllWorlds()
}
}
async function removeServer(server: ServerWorld) {
await remove_server_from_profile(instance.value.path, server.index).catch(handleError)
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
}
async function editWorld(path: string, name: string, removeIcon: boolean) {
const world = worlds.value.find((world) => world.type === 'singleplayer' && world.path === path)
if (world) {
world.name = name
if (removeIcon) {
world.icon = undefined
}
sortWorlds(worlds.value)
} else {
handleError(`Error finding world in list, refreshing all worlds`)
await refreshAllWorlds()
}
}
async function deleteWorld(world: SingleplayerWorld) {
await delete_world(instance.value.path, world.path).catch(handleError)
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
}
function handleJoinError(err: unknown) {
handleError(err)
startingInstance.value = false
worldPlaying.value = undefined
}
async function joinWorld(world: World) {
console.log(`Joining world ${getWorldIdentifier(world)}`)
startingInstance.value = true
worldPlaying.value = world
if (world.type === 'server') {
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
}
play(world)
startingInstance.value = false
}
watch(
() => playing.value,
(playing) => {
if (!playing) {
worldPlaying.value = undefined
setTimeout(async () => {
for (const world of worlds.value) {
if (world.type === 'singleplayer' && world.locked) {
await refreshWorld(worlds.value, instance.value.path, world.path)
}
}
}, 1000)
}
},
)
function worldsMatch(world: World, other: World | undefined) {
if (world.type === 'server' && other?.type === 'server') {
return world.address === other.address
} else if (world.type === 'singleplayer' && other?.type === 'singleplayer') {
return world.path === other.path
}
return false
}
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const supportsQuickPlay = computed(() =>
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const filterOptions = computed(() => {
const options: FilterBarOption[] = []
const hasServer = worlds.value.some((x) => x.type === 'server')
if (worlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
options.push({
id: 'singleplayer',
message: messages.singleplayer,
})
options.push({
id: 'server',
message: messages.server,
})
}
if (hasServer) {
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
if (
worlds.value.some(
(x) =>
x.type === 'server' &&
!serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing,
) &&
worlds.value.some(
(x) =>
x.type === 'singleplayer' ||
(x.type === 'server' &&
serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing),
)
) {
options.push({
id: 'available',
message: messages.available,
})
}
}
return options
})
const filteredWorlds = computed(() =>
worlds.value.filter((x) => {
const availableFilter = filters.value.includes('available')
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')
return (
(!typeFilter || filters.value.includes(x.type)) &&
(!availableFilter || x.type !== 'server' || serverData.value[x.address]?.status) &&
(!searchFilter.value || x.name.toLowerCase().includes(searchFilter.value.toLowerCase()))
)
}),
)
const highlightedWorld = ref(route.query.highlight)
function promptToRemoveWorld(world: World): boolean {
if (world.type === 'server') {
serverToRemove.value = world
removeServerModal.value?.show()
return !!removeServerModal.value
} else {
worldToDelete.value = world
deleteWorldModal.value?.show()
return !!deleteWorldModal.value
}
}
async function proceedRemoveServer() {
if (!serverToRemove.value) {
handleError(`Error removing server, no server marked for removal.`)
return
}
await removeServer(serverToRemove.value)
serverToRemove.value = undefined
}
async function proceedDeleteWorld() {
if (!worldToDelete.value) {
handleError(`Error deleting world, no world marked for removal.`)
return
}
await deleteWorld(worldToDelete.value)
worldToDelete.value = undefined
}
onUnmounted(() => {
unlistenProfile()
})
const messages = defineMessages({
singleplayer: {
id: 'instance.worlds.type.singleplayer',
defaultMessage: 'Singleplayer',
},
server: {
id: 'instance.worlds.type.server',
defaultMessage: 'Server',
},
available: {
id: 'instance.worlds.filter.available',
defaultMessage: 'Available',
},
})
</script>

View File

@@ -1,5 +1,7 @@
import Index from './Index.vue'
import Overview from './Overview.vue'
import Worlds from './Worlds.vue'
import Mods from './Mods.vue'
import Logs from './Logs.vue'
export { Index, Mods, Logs }
export { Index, Overview, Worlds, Mods, Logs }

View File

@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
@@ -170,6 +170,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime)
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
@@ -192,6 +193,11 @@ const [allLoaders, allGameVersions] = await Promise.all([
async function fetchProjectData() {
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
if (!project) {
handleError('Error loading project')
return
}
data.value = project
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
await Promise.all([
@@ -242,6 +248,9 @@ async function install(version) {
installedVersion.value = version
}
},
(profile) => {
router.push(`/instance/${profile}`)
},
)
}

View File

@@ -18,6 +18,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Home' }],
},
},
{
path: '/worlds',
name: 'Worlds',
component: Pages.Worlds,
meta: {
breadcrumb: [{ name: 'Worlds' }],
},
},
{
path: '/browse/:projectType',
name: 'Discover content',
@@ -26,6 +34,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Discover content' }],
},
},
{
path: '/skins',
name: 'Skins',
component: Pages.Skins,
meta: {
breadcrumb: [{ name: 'Skins' }],
},
},
{
path: '/library',
name: 'Library',
@@ -106,13 +122,31 @@ export default new createRouter({
component: Instance.Index,
props: true,
children: [
// {
// path: '',
// name: 'Overview',
// component: Instance.Overview,
// meta: {
// useRootContext: true,
// breadcrumb: [{ name: '?Instance' }],
// },
// },
{
path: 'worlds',
name: 'InstanceWorlds',
component: Instance.Worlds,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
},
},
{
path: '',
name: 'Mods',
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance' }],
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
@@ -121,7 +155,7 @@ export default new createRouter({
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance' }],
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{

View File

@@ -23,8 +23,8 @@ export const useInstall = defineStore('installStore', {
setInstallConfirmModal(ref) {
this.installConfirmModal = ref
},
showInstallConfirmModal(project, version_id, onInstall) {
this.installConfirmModal.show(project, version_id, onInstall)
showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) {
this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback)
},
setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref
@@ -41,7 +41,14 @@ export const useInstall = defineStore('installStore', {
},
})
export const install = async (projectId, versionId, instancePath, source, callback = () => {}) => {
export const install = async (
projectId,
versionId,
instancePath,
source,
callback = () => {},
createInstanceCallback = () => {},
) => {
const project = await get_project(projectId, 'must_revalidate').catch(handleError)
if (project.project_type === 'modpack') {
@@ -49,7 +56,13 @@ export const install = async (projectId, versionId, instancePath, source, callba
const packs = await list().catch(handleError)
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
await packInstall(project.id, version, project.title, project.icon_url).catch(handleError)
await packInstall(
project.id,
version,
project.title,
project.icon_url,
createInstanceCallback,
).catch(handleError)
trackEvent('PackInstall', {
id: project.id,
@@ -61,7 +74,7 @@ export const install = async (projectId, versionId, instancePath, source, callba
callback(version)
} else {
const install = useInstall()
install.showInstallConfirmModal(project, version, callback)
install.showInstallConfirmModal(project, version, callback, createInstanceCallback)
}
} else {
if (instancePath) {

View File

@@ -1,4 +1,4 @@
import { useTheming } from './theme'
import { useTheming } from './theme.ts'
import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading'
import { useNotifications, handleError } from './notifications'

View File

@@ -1,38 +0,0 @@
import { defineStore } from 'pinia'
export const useTheming = defineStore('themeStore', {
state: () => ({
themeOptions: ['dark', 'light', 'oled', 'system'],
advancedRendering: true,
selectedTheme: 'dark',
toggleSidebar: false,
devMode: false,
featureFlags: {},
}),
actions: {
setThemeState(newTheme) {
if (this.themeOptions.includes(newTheme)) this.selectedTheme = newTheme
else console.warn('Selected theme is not present. Check themeOptions.')
this.setThemeClass()
},
setThemeClass() {
for (const theme of this.themeOptions) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
},
})

View File

@@ -0,0 +1,70 @@
import { defineStore } from 'pinia'
export const DEFAULT_FEATURE_FLAGS = {
project_background: false,
page_path: false,
worlds_tab: false,
worlds_in_home: true,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
export type FeatureFlags = Record<FeatureFlag, boolean>
export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type ThemeStore = {
selectedTheme: ColorTheme
advancedRendering: boolean
toggleSidebar: boolean
devMode: boolean
featureFlags: FeatureFlags
}
export const DEFAULT_THEME_STORE: ThemeStore = {
selectedTheme: 'dark',
advancedRendering: true,
toggleSidebar: false,
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
}
export const useTheming = defineStore('themeStore', {
state: () => DEFAULT_THEME_STORE,
actions: {
setThemeState(newTheme: ColorTheme) {
if (THEME_OPTIONS.includes(newTheme)) {
this.selectedTheme = newTheme
} else {
console.warn('Selected theme is not present. Check themeOptions.')
}
this.setThemeClass()
},
setThemeClass() {
for (const theme of THEME_OPTIONS) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
},
getThemeOptions() {
return THEME_OPTIONS
},
},
})

View File

@@ -41,6 +41,7 @@ export default {
green: 'var(--color-green-highlight)',
blue: 'var(--color-blue-highlight)',
purple: 'var(--color-purple-highlight)',
gray: 'var(--color-gray-highlight)',
},
divider: {
DEFAULT: 'var(--color-divider)',

View File

@@ -10,6 +10,7 @@
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"resolveJsonModule": true,
"strict": true
},

View File

@@ -4,6 +4,8 @@ import svgLoader from 'vite-svg-loader'
import vue from '@vitejs/plugin-vue'
import tauriConf from '../app/tauri.conf.json'
const projectRootDir = resolve(__dirname)
// https://vitejs.dev/config/
@@ -41,17 +43,32 @@ export default defineConfig({
server: {
port: 1420,
strictPort: true,
headers: {
'content-security-policy': Object.entries(tauriConf.app.security.csp)
.map(([directive, sources]) => {
// An additional websocket connect-src is required for Vite dev tools to work
if (directive === 'connect-src') {
sources = Array.isArray(sources) ? sources : [sources]
sources.push('ws://localhost:1420')
}
return Array.isArray(sources)
? `${directive} ${sources.join(' ')}`
: `${directive} ${sources}`
})
.join('; '),
},
},
// to make use of `TAURI_ENV_DEBUG` and other env variables
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
envPrefix: ['VITE_', 'TAURI_'],
build: {
// Tauri supports es2021
target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
commonjsOptions: {
esmExternals: true,
},

View File

@@ -1,24 +1,14 @@
[package]
name = "theseus_playground"
version = "0.0.0"
edition = "2021"
edition.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
theseus = { path = "../../packages/app-lib", features = ["cli"] }
theseus = { workspace = true, features = ["cli"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
enumset.workspace = true
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
thiserror = "1.0"
url = "2.2"
webbrowser = "0.8.13"
dunce = "1.0.3"
futures = "0.3"
uuid = { version = "1.1", features = ["serde", "v4"] }
tracing = "0.1.37"
tracing-subscriber = "0.3.18"
tracing-error = "0.2.0"
[lints]
workspace = true

View File

@@ -2,9 +2,9 @@
"name": "@modrinth/app-playground",
"scripts": {
"build": "cargo build --release",
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
"fix": "cargo fmt && cargo clippy --fix",
"lint": "cargo fmt --check && cargo clippy --all-targets",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
"dev": "cargo run",
"test": "cargo test"
"test": "cargo nextest run --all-targets --no-fail-fast"
}
}

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