99 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
All checks were successful
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Successful in 33m50s
2025-07-08 05:06:39 +03:00
006fd7c7f5 fix: another sqlx migrations fix
Some checks failed
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Has been cancelled
2025-07-08 04:59:33 +03:00
1e8e001eb8 fix: Impl. fixes for all known migration issues from modrinth authors
All checks were successful
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Successful in 33m45s
2025-07-08 04:25:37 +03:00
585935c799 fix: Impl. fix for migration 20240711194701
All checks were successful
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Successful in 35m18s
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.
All checks were successful
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Successful in 38m33s
Bump to v0.10.302
2025-07-08 01:12:16 +03:00
ab57926e44 ref: Remove unused workflow steps
All checks were successful
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Successful in 38m6s
2025-07-07 19:20:40 +03:00
35cd79727a Merge commit 'c47bcf665d0686db29732629b36113d3b25915af' into feature-clean
Some checks failed
AstralRinth App build / Build (universal-apple-darwin, macos-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Has started running
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
Some checks failed
AstralRinth App build / Build (universal-apple-darwin, macos-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-latest) (push) Has been cancelled
2025-07-07 18:37:14 +03:00
d7e03fe2be another fix for github actions
Some checks failed
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
AstralRinth App build / Build (universal-apple-darwin, macos-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-22.04) (push) Has been cancelled
2025-07-07 18:20:37 +03:00
ba88244571 fix?
Some checks failed
AstralRinth App build / Build (universal-apple-darwin, macos-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-22.04) (push) Has been cancelled
2025-07-07 18:18:57 +03:00
d6d77256fe fix workflow
Some checks failed
AstralRinth App build / Build (universal-apple-darwin, macos-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-22.04) (push) Has been cancelled
2025-07-07 18:01:20 +03:00
7449a209fb update workflow
Some checks failed
AstralRinth App build / Build (universal-apple-darwin, macos) (push) Has been cancelled
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows) (push) Has been cancelled
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu) (push) Has been cancelled
2025-07-07 17:57:14 +03:00
81852859ca update workflow and tauri config
Some checks failed
AstralRinth App build / Build (x86_64-unknown-linux-gnu, ubuntu-22.04) (push) Failing after 24m38s
AstralRinth App build / Build (universal-apple-darwin, macos-latest) (push) Has been cancelled
AstralRinth App build / Build (x86_64-pc-windows-msvc, windows-latest) (push) Has been cancelled
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
Some checks failed
AstralRinth App Build / build (ubuntu-latest) (push) Successful in 23m47s
AstralRinth App Build / build (macos-latest) (push) Has been cancelled
AstralRinth App Build / build (windows-latest) (push) Has been cancelled
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
Some checks failed
AstralRinth App Build / build (ubuntu-latest) (push) Failing after 14m17s
AstralRinth App Build / build (macos-latest) (push) Has been cancelled
AstralRinth App Build / build (windows-latest) (push) Has been cancelled
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
528 changed files with 19234 additions and 3399 deletions

View File

@@ -1,6 +1,6 @@
# 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"]

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

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: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- 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 xdg-utils
- 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@v3
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>

801
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@ 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.24", default-features = false }
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",
@@ -37,6 +37,7 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [
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"
@@ -47,6 +48,7 @@ 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"
@@ -60,6 +62,8 @@ 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"
@@ -89,6 +93,7 @@ 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"
@@ -96,8 +101,9 @@ rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
regex = "1.11.1"
reqwest = { version = "0.12.19", default-features = false }
rust_decimal = { version = "1.37.1", features = [
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",
] }
@@ -108,7 +114,7 @@ rust-s3 = { version = "0.35.1", default-features = false, features = [
"tokio-rustls-tls",
] }
rusty-money = "0.4.1"
sentry = { version = "0.38.1", default-features = false, features = [
sentry = { version = "0.41.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
@@ -116,13 +122,13 @@ sentry = { version = "0.38.1", default-features = false, features = [
"reqwest",
"rustls",
] }
sentry-actix = "0.38.1"
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.12.0"
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"] }
@@ -131,18 +137,19 @@ 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.5.1"
tauri-build = "2.2.0"
tauri-plugin-deep-link = "2.3.0"
tauri-plugin-dialog = "2.2.2"
tauri-plugin-opener = "2.2.7"
tauri-plugin-os = "2.2.1"
tauri-plugin-single-instance = "2.2.4"
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
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.2.2"
tauri-plugin-window-state = "2.3.0"
tempfile = "3.20.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
@@ -165,7 +172,7 @@ whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "4.0.0", default-features = false, features = [
zip = { version = "4.2.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
@@ -212,7 +219,7 @@ wildcard_dependencies = "warn"
warnings = "deny"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
# Optimize for speed and reduce size on release builds
[profile.release]

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "0.9.5",
"version": "1.0.0-local",
"type": "module",
"scripts": {
"dev": "vite",
@@ -20,16 +20,20 @@
"@sentry/vue": "^8.27.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",

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,
@@ -18,6 +19,7 @@ import {
SettingsIcon,
WorldIcon,
XIcon,
NewspaperIcon,
} from '@modrinth/assets'
import {
Avatar,
@@ -25,7 +27,7 @@ import {
ButtonStyled,
Notifications,
OverflowMenu,
useRelativeTime,
NewsArticleCard,
} from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
// import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
@@ -62,14 +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'
const formatRelativeTime = useRelativeTime()
import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
const themeStore = useTheming()
@@ -187,31 +188,53 @@ async function setupApp() {
}),
)
//useFetch(
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
// 'criticalAnnouncements',
// true,
//)
// .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}`,
// )
// })
// Patched by AstralRinth
// useFetch(
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
// 'criticalAnnouncements',
// true,
// )
// .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)
@@ -319,6 +342,7 @@ onMounted(() => {
})
const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand)
async function handleCommand(e) {
@@ -414,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"
@@ -594,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">
{{ formatRelativeTime(dayjs(item.date).toISOString()) }}
</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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

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

@@ -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

@@ -19,7 +19,6 @@ import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs'
import { formatCategory } from '@modrinth/utils'
const formatRelativeTime = useRelativeTime()
@@ -173,7 +172,10 @@ onUnmounted(() => unlisten())
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon />
<span class="text-sm">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
<template v-if="instance.last_played">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</template>
<template v-else> Never played </template>
</span>
</div>
</div>
@@ -237,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

@@ -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

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

View File

@@ -38,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
@@ -48,7 +48,7 @@
</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>
@@ -66,14 +66,30 @@
<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">
@@ -129,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)
@@ -375,7 +395,7 @@ onBeforeUnmount(() => {
text-shadow: #26065e;
}
.download-modal {
.updater-modal {
color: #3e8cde;
padding: var(--gap-sm) var(--gap-lg);
text-decoration: none;
@@ -386,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

@@ -5,7 +5,7 @@ import {
ShieldIcon,
SettingsIcon,
GaugeIcon,
PaintBrushIcon,
PaintbrushIcon,
GameIcon,
CoffeeIcon,
} from '@modrinth/assets'
@@ -41,7 +41,7 @@ const tabs = [
id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance',
}),
icon: PaintBrushIcon,
icon: PaintbrushIcon,
content: AppearanceSettings,
},
{

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

@@ -56,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" />

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

@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import {
EyeIcon,
@@ -42,6 +43,7 @@ const emit = defineEmits<{
const props = defineProps<{
instance: GameInstance
last_played: Dayjs
}>()
const loadingModpack = ref(!!props.instance.linked_data)
@@ -147,12 +149,12 @@ onUnmounted(() => {
: null
"
class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
>
<template v-if="instance.last_played">
<template v-if="last_played">
{{
formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(instance.last_played.toISOString()),
time: formatRelativeTime(last_played.toISOString?.()),
})
}}
</template>

View File

@@ -84,7 +84,7 @@ async function populateJumpBackIn() {
worldItems.push({
type: 'world',
last_played: dayjs(world.last_played),
last_played: dayjs(world.last_played ?? 0),
world: world,
instance: instance,
})
@@ -138,13 +138,13 @@ async function populateJumpBackIn() {
instanceItems.push({
type: 'instance',
last_played: dayjs(instance.last_played),
last_played: dayjs(instance.last_played ?? 0),
instance: instance,
})
}
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
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)
@@ -291,7 +291,7 @@ onUnmounted(() => {
"
@stop="() => stopInstance(item.instance.path)"
/>
<InstanceItem v-else :instance="item.instance" />
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
</template>
</div>
</div>

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

@@ -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

@@ -37,6 +37,7 @@ export type AppSettings = {
theme: ColorTheme
default_page: 'home' | 'library'
collapsed_navigation: boolean
hide_nametag_skins_page: boolean
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean

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

@@ -2,21 +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 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 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`,
@@ -26,110 +24,73 @@ const blacklistedBuilds = [
`dirty_nightly`,
] // This is blacklisted builds for download. For example, file.startsWith('dev') is not allowed.
/**
* 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
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;
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)
if (osList.includes(currentOS.value.toLowerCase())) {
const localVersion = await getVersion();
const isUpdateAvailable = !remoteVersion.includes(localVersion);
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
}
})
.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
}
})
}
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;
}
/**
* 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)
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}`;
}
})
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

@@ -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

@@ -10,6 +10,7 @@ import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js'
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()
@@ -82,13 +83,15 @@ async function refreshFeaturedProjects() {
await fetchInstances()
await refreshFeaturedProjects()
const unlistenProfile = await profile_listener(async (e) => {
await fetchInstances()
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 refreshFeaturedProjects()
}
})
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await refreshFeaturedProjects()
}
},
)
onUnmounted(() => {
unlistenProfile()
@@ -97,8 +100,8 @@ 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="hasFeaturedProjects"

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

@@ -1,5 +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, Worlds }
export { Index, Browse, Worlds, Skins }

View File

@@ -34,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',

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

@@ -27,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
let credentials = minecraft_auth::finish_login(&input, login).await?;
println!("Logged in user {}.", credentials.username);
println!(
"Logged in user {}.",
credentials.maybe_online_profile().await.name
);
Ok(credentials)
}

4
apps/app/.gitignore vendored
View File

@@ -1,6 +1,2 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by tauri, metadata generated at compile time
/gen/

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.9.5"
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/"
@@ -17,13 +17,14 @@ serde = { workspace = true, features = ["derive"] }
serde_with.workspace = true
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
tauri-plugin-window-state.workspace = true
tauri-plugin-deep-link.workspace = true
tauri-plugin-os.workspace = true
tauri-plugin-opener.workspace = true
tauri-plugin-dialog.workspace = true
tauri-plugin-updater.workspace = true
tauri-plugin-http.workspace = true
tauri-plugin-opener.workspace = true
tauri-plugin-os.workspace = true
tauri-plugin-single-instance.workspace = true
tauri-plugin-updater.workspace = true
tauri-plugin-window-state.workspace = true
tokio = { workspace = true, features = ["time"] }
thiserror.workspace = true

View File

@@ -18,5 +18,25 @@
<string>A Minecraft mod wants to access your camera.</string>
<key>NSMicrophoneUsageDescription</key>
<string>A Minecraft mod wants to access your microphone.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>asset.localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>textures.minecraft.net</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>

View File

@@ -100,6 +100,24 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"minecraft-skins",
InlinedPlugin::new()
.commands(&[
"get_available_capes",
"get_available_skins",
"add_and_equip_custom_skin",
"set_default_cape",
"equip_skin",
"remove_custom_skin",
"unequip_skin",
"normalize_skin_texture",
"get_dragged_skin_data",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"mr-auth",
InlinedPlugin::new()
@@ -152,7 +170,6 @@ fn main() {
"profile_update_managed_modrinth_version",
"profile_repair_managed_modrinth",
"profile_run",
"profile_run_credentials",
"profile_kill",
"profile_edit",
"profile_edit_icon",

View File

@@ -19,12 +19,21 @@
"window-state:default",
"window-state:allow-restore-state",
"window-state:allow-save-window-state",
{
"identifier": "http:default",
"allow": [
{ "url": "https://modrinth.com/*" },
{ "url": "https://*.modrinth.com/*" }
]
},
"auth:default",
"import:default",
"jre:default",
"logs:default",
"metadata:default",
"minecraft-skins:default",
"mr-auth:default",
"profile-create:default",
"pack:default",

View File

@@ -41,8 +41,8 @@ pub async fn jre_find_filtered_jres(
// Validates JRE at a given path
// Returns None if the path is not a valid JRE
#[tauri::command]
pub async fn jre_get_jre(path: PathBuf) -> Result<Option<JavaVersion>> {
jre::check_jre(path).await.map_err(|e| e.into())
pub async fn jre_get_jre(path: PathBuf) -> Result<JavaVersion> {
Ok(jre::check_jre(path).await?)
}
// Tests JRE of a certain version

View File

@@ -0,0 +1,104 @@
use crate::api::Result;
use std::path::Path;
use theseus::minecraft_skins::{
self, Bytes, Cape, MinecraftSkinVariant, Skin, UrlOrBlob,
};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("minecraft-skins")
.invoke_handler(tauri::generate_handler![
get_available_capes,
get_available_skins,
add_and_equip_custom_skin,
set_default_cape,
equip_skin,
remove_custom_skin,
unequip_skin,
normalize_skin_texture,
get_dragged_skin_data,
])
.build()
}
/// `invoke('plugin:minecraft-skins|get_available_capes')`
///
/// See also: [minecraft_skins::get_available_capes]
#[tauri::command]
pub async fn get_available_capes() -> Result<Vec<Cape>> {
Ok(minecraft_skins::get_available_capes().await?)
}
/// `invoke('plugin:minecraft-skins|get_available_skins')`
///
/// See also: [minecraft_skins::get_available_skins]
#[tauri::command]
pub async fn get_available_skins() -> Result<Vec<Skin>> {
Ok(minecraft_skins::get_available_skins().await?)
}
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
///
/// See also: [minecraft_skins::add_and_equip_custom_skin]
#[tauri::command]
pub async fn add_and_equip_custom_skin(
texture_blob: Bytes,
variant: MinecraftSkinVariant,
cape_override: Option<Cape>,
) -> Result<()> {
Ok(minecraft_skins::add_and_equip_custom_skin(
texture_blob,
variant,
cape_override,
)
.await?)
}
/// `invoke('plugin:minecraft-skins|set_default_cape', cape)`
///
/// See also: [minecraft_skins::set_default_cape]
#[tauri::command]
pub async fn set_default_cape(cape: Option<Cape>) -> Result<()> {
Ok(minecraft_skins::set_default_cape(cape).await?)
}
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
///
/// See also: [minecraft_skins::equip_skin]
#[tauri::command]
pub async fn equip_skin(skin: Skin) -> Result<()> {
Ok(minecraft_skins::equip_skin(skin).await?)
}
/// `invoke('plugin:minecraft-skins|remove_custom_skin', skin)`
///
/// See also: [minecraft_skins::remove_custom_skin]
#[tauri::command]
pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
Ok(minecraft_skins::remove_custom_skin(skin).await?)
}
/// `invoke('plugin:minecraft-skins|unequip_skin')`
///
/// See also: [minecraft_skins::unequip_skin]
#[tauri::command]
pub async fn unequip_skin() -> Result<()> {
Ok(minecraft_skins::unequip_skin().await?)
}
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
///
/// See also: [minecraft_skins::normalize_skin_texture]
#[tauri::command]
pub async fn normalize_skin_texture(texture: UrlOrBlob) -> Result<Bytes> {
Ok(minecraft_skins::normalize_skin_texture(&texture).await?)
}
/// `invoke('plugin:minecraft-skins|get_dragged_skin_data', path)`
///
/// See also: [minecraft_skins::get_dragged_skin_data]
#[tauri::command]
pub async fn get_dragged_skin_data(path: String) -> Result<Bytes> {
let path = Path::new(&path);
Ok(minecraft_skins::get_dragged_skin_data(path).await?)
}

View File

@@ -7,6 +7,7 @@ pub mod import;
pub mod jre;
pub mod logs;
pub mod metadata;
pub mod minecraft_skins;
pub mod mr_auth;
pub mod pack;
pub mod process;

View File

@@ -28,7 +28,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_update_managed_modrinth_version,
profile_repair_managed_modrinth,
profile_run,
profile_run_credentials,
profile_kill,
profile_edit,
profile_edit_icon,
@@ -256,22 +255,6 @@ pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
Ok(process)
}
// Run Minecraft using a profile using chosen credentials
// Returns the UUID, which can be used to poll
// for the actual Child in the state.
// invoke('plugin:profile|profile_run_credentials', {path, credentials})')
#[tauri::command]
pub async fn profile_run_credentials(
path: &str,
credentials: Credentials,
) -> Result<ProcessMetadata> {
let process =
profile::run_credentials(path, &credentials, &QuickPlayType::None)
.await?;
Ok(process)
}
#[tauri::command]
pub async fn profile_kill(path: &str) -> Result<()> {
profile::kill(path).await?;

View File

@@ -34,9 +34,6 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
// let update_fut = updater.check();
// tracing::info!("Initializing app state...");
State::init().await?;
// let check_bar = theseus::init_loading(
// theseus::LoadingBarType::CheckingForUpdates,
// 1.0,
@@ -87,7 +84,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
// #[cfg(not(feature = "updater"))]
// {
// }
tracing::info!("Initializing app state...");
State::init().await?;
tracing::info!("AstralRinth state successfully initialized.");
let state = State::get().await?;
@@ -164,10 +161,10 @@ fn main() {
let mut builder = tauri::Builder::default();
#[cfg(feature = "updater")]
{
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
}
// #[cfg(feature = "updater")]
// {
// builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
// }
builder = builder
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
@@ -183,6 +180,7 @@ fn main() {
let _ = win.set_focus();
}
}))
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_deep_link::init())
@@ -261,6 +259,7 @@ fn main() {
.plugin(api::logs::init())
.plugin(api::jre::init())
.plugin(api::metadata::init())
.plugin(api::minecraft_skins::init())
.plugin(api::pack::init())
.plugin(api::process::init())
.plugin(api::profile::init())

View File

@@ -14,9 +14,6 @@
"externalBin": [],
"icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com",
"nsis": {
"installMode": "perMachine",
"installerHooks": "./nsis/hooks.nsi"
@@ -30,7 +27,6 @@
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"linux": {
"deb": {
@@ -45,7 +41,7 @@
]
},
"productName": "AstralRinth App",
"version": "0.9.501",
"version": "0.10.302",
"mainBinaryName": "AstralRinth App",
"identifier": "AstralRinthApp",
"plugins": {
@@ -90,9 +86,9 @@
"capabilities": ["core", "plugins"],
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "https://git.astralium.su ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
"connect-src": "ipc: https://git.astralium.su http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'",

View File

@@ -1,4 +1,4 @@
FROM rust:1.87.0 AS build
FROM rust:1.88.0 AS build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /usr/src/daedalus

View File

@@ -68,7 +68,7 @@
Support: https://support.modrinth.com
Status page: https://status.modrinth.com
Roadmap: https://roadmap.modrinth.com
Blog and newsletter: https://blog.modrinth.com/subscribe?utm_medium=social&utm_source=discord&utm_campaign=welcome
Blog and newsletter: https://modrinth.com/news
API documentation: https://docs.modrinth.com
Modrinth source code: https://github.com/modrinth
Help translate Modrinth: https://crowdin.com/project/modrinth

View File

@@ -85,11 +85,10 @@ During development, you might notice that changes made directly to entities in t
#### CDN options
`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local`, `backblaze`, or `s3`, but defaults to `local`
`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local` or `s3`, but defaults to `local`
The Backblaze and S3 configuration options are fairly self-explanatory in name, so here's simply their names:
`BACKBLAZE_KEY_ID`, `BACKBLAZE_KEY`, `BACKBLAZE_BUCKET_ID`
`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_BUCKET_NAME`
The S3 configuration options are fairly self-explanatory in name, so here's simply their names:
`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_PUBLIC_BUCKET_NAME`, `S3_PRIVATE_BUCKET_NAME`, `S3_USES_PATH_STYLE_BUCKETS`
#### Search, OAuth, and miscellaneous options

View File

@@ -40,7 +40,9 @@
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@modrinth/blog": "workspace:*",
"@pinia/nuxt": "^0.5.1",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
@@ -59,7 +61,6 @@
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"three": "^0.172.0",
"@types/three": "^0.172.0",
"vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",

View File

@@ -1,15 +1,20 @@
<template>
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
<nuxt-link to="/plus" class="mt-auto items-center gap-1 text-purple hover:underline">
<span>
Support creators and Modrinth ad-free with
<span class="font-bold">Modrinth+</span>
</span>
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
</nuxt-link>
</div>
<nuxt-link
to="/servers"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
alt="Host your next server with Modrinth Servers"
class="light-image hidden rounded-[inherit]"
/>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
alt="Host your next server with Modrinth Servers"
class="dark-image rounded-[inherit]"
/>
</nuxt-link>
<div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
>
@@ -18,8 +23,6 @@
</div>
</template>
<script setup>
import { ChevronRightIcon } from "@modrinth/assets";
useHead({
script: [
// {
@@ -137,3 +140,16 @@ iframe[id^="google_ads_iframe"] {
}
}
</style>
<style lang="scss" scoped>
.light,
.light-mode {
.dark-image {
display: none;
}
.light-image {
display: block;
}
}
</style>

View File

@@ -9,7 +9,7 @@
</h1>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button v-tooltip="`Exit moderation`" @click="exitModeration">
<CrossIcon />
<XIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
@@ -306,7 +306,7 @@
<div class="flex items-center gap-2">
<ButtonStyled v-if="!done">
<button aria-label="Skip" @click="goToNextProject">
<ExitIcon aria-hidden="true" />
<XIcon aria-hidden="true" />
<template v-if="futureProjects.length > 0">Skip</template>
<template v-else>Exit</template>
</button>
@@ -335,7 +335,7 @@
<div class="joined-buttons">
<ButtonStyled color="red">
<button @click="sendMessage('rejected')">
<CrossIcon aria-hidden="true" /> Reject
<XIcon aria-hidden="true" /> Reject
</button>
</ButtonStyled>
<ButtonStyled color="red">
@@ -373,9 +373,8 @@ import {
UpdatedIcon,
CheckIcon,
DropdownIcon,
XIcon as CrossIcon,
EyeOffIcon,
ExitIcon,
XIcon,
ScaleIcon,
} from "@modrinth/assets";
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { MailIcon, CheckIcon } from "@modrinth/assets";
import { ref, watchEffect } from "vue";
import { useBaseFetch } from "~/composables/fetch.js";
const auth = await useAuth();
const showSubscriptionConfirmation = ref(false);
const subscribed = ref(false);
async function checkSubscribed() {
if (auth.value?.user) {
try {
const { data } = await useBaseFetch("auth/email/subscribe", {
method: "GET",
});
subscribed.value = data?.subscribed || false;
} catch {
subscribed.value = false;
}
}
}
watchEffect(() => {
checkSubscribed();
});
async function subscribe() {
try {
await useBaseFetch("auth/email/subscribe", {
method: "POST",
});
showSubscriptionConfirmation.value = true;
} catch {
} finally {
setTimeout(() => {
showSubscriptionConfirmation.value = false;
subscribed.value = true;
}, 2500);
}
}
</script>
<template>
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined">
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
<template v-else> <CheckIcon /> Subscribed! </template>
</button>
</ButtonStyled>
</template>

View File

@@ -1,5 +1,8 @@
<template>
<div class="vue-notification-group experimental-styles-within">
<div
class="vue-notification-group experimental-styles-within"
:class="{ 'intercom-present': isIntercomPresent }"
>
<transition-group name="notifs">
<div
v-for="(item, index) in notifications"
@@ -80,6 +83,8 @@ import {
} from "@modrinth/assets";
const notifications = useNotifications();
const isIntercomPresent = ref(false);
function stopTimer(notif) {
clearTimeout(notif.timer);
}
@@ -106,6 +111,27 @@ const createNotifText = (notif) => {
return text;
};
function checkIntercomPresence() {
isIntercomPresent.value = !!document.querySelector(".intercom-lightweight-app");
}
onMounted(() => {
checkIntercomPresence();
const observer = new MutationObserver(() => {
checkIntercomPresence();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
onBeforeUnmount(() => {
observer.disconnect();
});
});
function copyToClipboard(notif) {
const text = createNotifText(notif);
@@ -130,6 +156,10 @@ function copyToClipboard(notif) {
bottom: 0.75rem;
}
&.intercom-present {
bottom: 5rem;
}
.vue-notification-wrapper {
width: 100%;
overflow: hidden;

View File

@@ -57,7 +57,7 @@
<div class="table-cell">
<BoxIcon />
<span>{{
$formatProjectType(
formatProjectType(
$getProjectTypeForDisplay(
project.project_types?.[0] ?? "project",
project.loaders,
@@ -111,6 +111,7 @@
<script setup>
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from "@modrinth/assets";
import { Button, Modal, Checkbox, CopyCode, Avatar } from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
const modalOpen = ref(null);

View File

@@ -118,7 +118,7 @@ import {
ScaleIcon,
DropdownIcon,
} from "@modrinth/assets";
import { formatProjectType } from "~/plugins/shorthands.js";
import { formatProjectType } from "@modrinth/utils";
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
const props = defineProps({

View File

@@ -0,0 +1,86 @@
<template>
<div class="flex gap-2">
<ButtonStyled circular>
<a
v-tooltip="`Share on Bluesky`"
:href="`https://bsky.app/intent/compose?text=${encodedUrl}`"
target="_blank"
>
<BlueskyIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share on Mastodon`"
:href="`https://tootpick.org/#text=${encodedUrl}`"
target="_blank"
>
<MastodonIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share on X`"
:href="`https://www.x.com/intent/post?url=${encodedUrl}`"
target="_blank"
>
<TwitterIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share via email`"
:href="`mailto:${encodedTitle ? `?subject=${encodedTitle}&` : `?`}body=${encodedUrl}`"
target="_blank"
>
<MailIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="copied ? `Copied to clipboard` : `Copy link`"
:disabled="copied"
class="relative grid place-items-center overflow-hidden"
@click="copyToClipboard(url)"
>
<CheckIcon
class="absolute transition-all ease-in-out"
:class="copied ? 'translate-y-0' : 'translate-y-7'"
/>
<LinkIcon
class="absolute transition-all ease-in-out"
:class="copied ? '-translate-y-7' : 'translate-y-0'"
/>
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import {
BlueskyIcon,
CheckIcon,
LinkIcon,
MailIcon,
MastodonIcon,
TwitterIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
const props = defineProps<{
title?: string;
url: string;
}>();
const copied = ref(false);
const encodedUrl = computed(() => encodeURIComponent(props.url));
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined));
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 3000);
}
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="mx-2 p-4 !py-8 sm:mx-8 sm:p-32">
<div class="my-8 flex items-center justify-between">
<h2 class="m-0 mx-auto text-3xl font-extrabold sm:text-4xl">Latest news from Modrinth</h2>
</div>
<div v-if="latestArticles" class="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4">
<div
v-for="(article, index) in latestArticles"
:key="article.slug"
:class="{ 'max-xl:hidden': index === 2 }"
>
<NewsArticleCard :article="article" />
</div>
</div>
<div class="mx-2 my-8 flex w-full items-center justify-center">
<ButtonStyled color="brand" size="large">
<nuxt-link to="/news">
<NewspaperIcon />
View all news
</nuxt-link>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { NewspaperIcon } from "@modrinth/assets";
import { articles as rawArticles } from "@modrinth/blog";
import { ButtonStyled, NewsArticleCard } from "@modrinth/ui";
import { ref, computed } from "vue";
const articles = ref(
rawArticles
.map((article) => ({
...article,
path: `/news/article/${article.slug}/`,
thumbnail: article.thumbnail
? `/news/article/${article.slug}/thumbnail.webp`
: `/news/default.webp`,
title: article.title,
summary: article.summary,
date: article.date,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
);
const latestArticles = computed(() => articles.value.slice(0, 3));
</script>

View File

@@ -11,8 +11,8 @@
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
$formatProjectType(
$getProjectTypeForUrl(report.project.project_type, report.project.loaders),
formatProjectType(
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}</span>
</div>
@@ -53,8 +53,8 @@
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
$formatProjectType(
$getProjectTypeForUrl(report.project.project_type, report.project.loaders),
formatProjectType(
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}</span>
</div>
@@ -105,8 +105,10 @@
<script setup>
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
import { Avatar, Badge, CopyCode, useRelativeTime } from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
import { renderHighlightedString } from "~/helpers/highlight.js";
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
import { getProjectTypeForUrl } from "~/helpers/projects.js";
const formatRelativeTime = useRelativeTime();

View File

@@ -4,12 +4,14 @@
<span
v-for="category in categoriesFiltered"
:key="category.name"
v-html="category.icon + $formatCategory(category.name)"
v-html="category.icon + formatCategory(category.name)"
/>
</div>
</template>
<script>
import { formatCategory } from "@modrinth/utils";
export default {
props: {
categories: {
@@ -38,6 +40,7 @@ export default {
);
},
},
methods: { formatCategory },
};
</script>

View File

@@ -16,7 +16,7 @@ import {
} from "@modrinth/assets";
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from "@modrinth/ui";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { ref } from "vue";
import { ref, computed } from "vue";
import type { Backup } from "@modrinth/utils";
const flags = useFeatureFlags();
@@ -52,9 +52,10 @@ const failedToCreate = computed(() => props.backup.interrupted);
const preparedDownloadStates = ["ready", "done"];
const inactiveStates = ["failed", "cancelled"];
const hasPreparedDownload = computed(() =>
preparedDownloadStates.includes(props.backup.task?.file?.state ?? ""),
);
const hasPreparedDownload = computed(() => {
const fileState = props.backup.task?.file?.state ?? "";
return preparedDownloadStates.includes(fileState);
});
const creating = computed(() => {
const task = props.backup.task?.create;
@@ -81,6 +82,10 @@ const restoring = computed(() => {
const initiatedPrepare = ref(false);
const preparingFile = computed(() => {
if (hasPreparedDownload.value) {
return false;
}
const task = props.backup.task?.file;
return (
(!task && initiatedPrepare.value) ||

View File

@@ -2,7 +2,7 @@
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
<template #title>
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
<UiAvatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
<Avatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
</div>
</template>
@@ -185,7 +185,7 @@
Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}.
Please try again later or contact support if the issue persists.
</span>
<LazyUiCopyCode class="!mt-2 !break-all" :text="versionsError" />
<CopyCode class="!mt-2 !break-all" :text="versionsError" />
</div>
</Admonition>
@@ -236,7 +236,7 @@ import {
GameIcon,
ExternalIcon,
} from "@modrinth/assets";
import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
import { Admonition, Avatar, ButtonStyled, CopyCode, NewModal } from "@modrinth/ui";
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
import { ref, computed } from "vue";
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";

View File

@@ -39,7 +39,7 @@
/>
<XCircleIcon
v-show="
item.status === 'error' ||
item.status.includes('error') ||
item.status === 'cancelled' ||
item.status === 'incorrect-type'
"
@@ -54,9 +54,14 @@
<template v-if="item.status === 'completed'">
<span>Done</span>
</template>
<template v-else-if="item.status === 'error'">
<template v-else-if="item.status === 'error-file-exists'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'error-generic'">
<span class="text-red"
>Failed - {{ item.error?.message || "An unexpected error occured." }}</span
>
</template>
<template v-else-if="item.status === 'incorrect-type'">
<span class="text-red">Failed - Incorrect file type</span>
</template>
@@ -104,9 +109,17 @@ import { FSModule } from "~/composables/servers/modules/fs.ts";
interface UploadItem {
file: File;
progress: number;
status: "pending" | "uploading" | "completed" | "error" | "cancelled" | "incorrect-type";
status:
| "pending"
| "uploading"
| "completed"
| "error-file-exists"
| "error-generic"
| "cancelled"
| "incorrect-type";
size: string;
uploader?: any;
error?: Error;
}
interface Props {
@@ -245,8 +258,18 @@ const uploadFile = async (file: File) => {
console.error("Error uploading file:", error);
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
uploadQueue.value[index].status =
error instanceof Error && error.message === badFileTypeMsg ? "incorrect-type" : "error";
const target = uploadQueue.value[index];
if (error instanceof Error) {
if (error.message === badFileTypeMsg) {
target.status = "incorrect-type";
} else if (target.progress === 100 && error.message.includes("401")) {
target.status = "error-file-exists";
} else {
target.status = "error-generic";
target.error = error;
}
}
}
setTimeout(async () => {

View File

@@ -1,124 +1,176 @@
<template>
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow">
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]">
<p
v-if="isMrpackModalSecondPhase"
:style="{
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
}"
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
This will reinstall your server and erase all data. You may want to back up your server
before proceeding. Are you sure you want to continue?
</p>
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UploadIcon class="size-10" />
<div v-if="isLoading" class="w-full">
<div class="mb-2 flex justify-between text-sm">
<Transition name="phrase-fade" mode="out-in">
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
currentPhrase
}}</span>
</Transition>
<div class="flex flex-col items-end">
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
>
</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 9v6" />
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
</svg>
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
<div class="h-2 w-full rounded-full bg-divider">
<div
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
:style="{ width: `${uploadProgress}%` }"
></div>
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
</Transition>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version.
</div>
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || backupInProgress"
@click="handleReinstall"
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="!isLoading" class="flex flex-col gap-4">
<p
v-if="isMrpackModalSecondPhase"
:style="{
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
}"
>
<RightArrowIcon />
{{
isMrpackModalSecondPhase
? "Erase and install"
: loadingServerCheck
? "Loading..."
: isDangerous
This will reinstall your server and erase all data. You may want to back up your server
before proceeding. Are you sure you want to continue?
</p>
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UploadIcon class="size-10" />
</div>
<ArrowBigRightDashIcon class="size-10" />
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration
files, then reinstalls it with the selected version.
</div>
<div class="font-bold">
This does not affect your backups, which are stored off-site.
</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? backupInProgress.tooltip : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isMrpackModalSecondPhase
? "Erase and install"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false;
} else {
hide();
}
}
"
>
<XIcon />
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div>
: loadingServerCheck
? "Loading..."
: isDangerous
? "Erase and install"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false;
} else {
hide();
}
}
"
>
<XIcon />
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</div>
</Transition>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
import { ModrinthServersFetchError } from "@modrinth/utils";
import {
UploadIcon,
RightArrowIcon,
XIcon,
ServerIcon,
ArrowBigRightDashIcon,
} from "@modrinth/assets";
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
import { onMounted, onUnmounted } from "vue";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { ModrinthServer } from "~/composables/servers/modrinth-servers";
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isLoading.value) {
event.preventDefault();
return "Upload in progress. Are you sure you want to leave?";
}
};
onMounted(() => {
window.addEventListener("beforeunload", handleBeforeUnload);
});
onUnmounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
});
const props = defineProps<{
server: ModrinthServer;
@@ -135,6 +187,49 @@ const hardReset = ref(false);
const isLoading = ref(false);
const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null);
const uploadProgress = ref(0);
const uploadedBytes = ref(0);
const totalBytes = ref(0);
const uploadPhrases = [
"Removing Herobrine...",
"Feeding parrots...",
"Teaching villagers new trades...",
"Convincing creepers to be friendly...",
"Polishing diamonds...",
"Training wolves to fetch...",
"Building pixel art...",
"Explaining redstone to beginners...",
"Collecting all the cats...",
"Negotiating with endermen...",
"Planting suspicious stew ingredients...",
"Calibrating TNT blast radius...",
"Teaching chickens to fly...",
"Sorting inventory alphabetically...",
"Convincing iron golems to smile...",
];
const currentPhrase = ref("Uploading...");
let phraseInterval: NodeJS.Timeout | null = null;
const usedPhrases = ref(new Set<number>());
const getNextPhrase = () => {
if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
usedPhrases.value.clear();
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex);
}
}
const availableIndices = uploadPhrases
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index));
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
usedPhrases.value.add(randomIndex);
return uploadPhrases[randomIndex];
};
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
@@ -153,18 +248,46 @@ const handleReinstall = async () => {
return;
}
if (!mrpackFile.value) {
addNotification({
group: "server",
title: "No file selected",
text: "Choose a .mrpack file before installing.",
type: "error",
});
return;
}
isLoading.value = true;
uploadProgress.value = 0;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = mrpackFile.value.size;
currentPhrase.value = getNextPhrase();
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase();
}, 4500);
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value,
hardReset.value,
);
onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress;
uploadedBytes.value = loaded;
totalBytes.value = total;
if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval);
phraseInterval = null;
currentPhrase.value = "Installing modpack...";
}
});
try {
if (!mrpackFile.value) {
throw new Error("No mrpack file selected");
}
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
type: mrpackFile.value.type,
});
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
await promise;
emit("reinstall", {
loader: "mrpack",
@@ -176,36 +299,44 @@ const handleReinstall = async () => {
window.scrollTo(0, 0);
hide();
} catch (error) {
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",
title: "Cannot upload and install modpack to server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
} else {
addNotification({
group: "server",
title: "Reinstall Failed",
text: "An unexpected error occurred while reinstalling. Please try again later.",
title: "Modpack upload and install failed",
text: "An unexpected error occurred while uploading/installing. Please try again later.",
type: "error",
});
}
} finally {
isLoading.value = false;
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
}
};
const onShow = () => {
hardReset.value = false;
isMrpackModalSecondPhase.value = false;
loadingServerCheck.value = false;
isLoading.value = false;
mrpackFile.value = null;
};
const onHide = () => {
onShow();
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = 0;
currentPhrase.value = "Uploading...";
usedPhrases.value.clear();
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
};
const show = () => mrpackModal.value?.show();
@@ -218,4 +349,14 @@ defineExpose({ show, hide });
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
.phrase-fade-enter-active,
.phrase-fade-leave-active {
transition: opacity 0.3s ease;
}
.phrase-fade-enter-from,
.phrase-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -33,7 +33,7 @@
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
>
<UiAvatar
<Avatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
@@ -74,7 +74,7 @@
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
update your billing information or contact Modrinth Support for more information.
</div>
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
</NuxtLink>
</template>
@@ -83,6 +83,7 @@
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
import type { Project, Server } from "@modrinth/utils";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import { Avatar, CopyCode } from "@modrinth/ui";
const props = defineProps<Partial<Server>>();

View File

@@ -50,9 +50,7 @@
</ClientOnly>
</div>
</div>
<component
:is="loading ? 'div' : 'NuxtLink'"
<nuxt-link
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
@@ -64,16 +62,17 @@
</div>
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</component>
</nuxt-link>
</div>
</template>
<script setup lang="ts">
import { ref, computed, shallowRef } from "vue";
import { FolderOpenIcon, CPUIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
import { FolderOpenIcon, CpuIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core";
import type { Stats } from "@modrinth/utils";
const flags = useFeatureFlags();
const route = useNativeRoute();
const serverId = route.params.id;
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
@@ -127,7 +126,7 @@ const metrics = computed(() => {
title: "CPU usage",
value: "0.00%",
max: "100%",
icon: CPUIcon,
icon: CpuIcon,
data: cpuData.value,
showGraph: false,
warning: null,
@@ -158,17 +157,21 @@ const metrics = computed(() => {
title: "CPU usage",
value: `${cpuPercent.toFixed(2)}%`,
max: "100%",
icon: CPUIcon,
icon: CpuIcon,
data: cpuData.value,
showGraph: true,
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
},
{
title: "Memory usage",
value: userPreferences.value.ramAsNumber
? formatBytes(stats.value.ram_usage_bytes)
: `${ramPercent.toFixed(2)}%`,
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
value:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_usage_bytes)
: `${ramPercent.toFixed(2)}%`,
max:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_total_bytes)
: "100%",
icon: DatabaseIcon,
data: ramData.value,
showGraph: true,

View File

@@ -1,18 +1,26 @@
import { createFormatter, type Formatter } from "@vintl/compact-number";
import type { IntlController } from "@vintl/vintl/controller";
const formatters = new WeakMap<object, Intl.NumberFormat>();
const formatters = new WeakMap<IntlController<any>, Formatter>();
export function useCompactNumber(truncate = false, fractionDigits = 2, locale?: string) {
const context = {};
export function useCompactNumber(): Formatter {
const vintl = useVIntl();
let formatter = formatters.get(context);
let formatter = formatters.get(vintl);
if (formatter == null) {
const formatterRef = computed(() => createFormatter(vintl.intl));
formatter = (value, options) => formatterRef.value(value, options);
formatters.set(vintl, formatter);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
notation: "compact",
maximumFractionDigits: fractionDigits,
});
formatters.set(context, formatter);
}
return formatter;
function format(value: number): string {
let formattedValue = value;
if (truncate) {
const scale = Math.pow(10, fractionDigits);
formattedValue = Math.floor(value * scale) / scale;
}
return formatter!.format(formattedValue);
}
return format;
}

View File

@@ -31,6 +31,9 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
projectBackground: false,
searchBackground: false,
advancedDebugInfo: false,
showProjectPageDownloadModalServersPromo: false,
showProjectPageCreateServersTooltip: true,
showProjectPageQuickServerButton: false,
// advancedRendering: true,
// externalLinksNewTab: true,
// notUsingBlockers: false,

View File

@@ -98,28 +98,67 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
}
}
async reinstallFromMrpack(mrpack: File, hardReset: boolean = false): Promise<void> {
reinstallFromMrpack(
mrpack: File,
hardReset: boolean = false,
): {
promise: Promise<void>;
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void;
} {
const hardResetParam = hardReset ? "true" : "false";
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
const formData = new FormData();
formData.append("file", mrpack);
const progressSubject = new EventTarget();
const response = await fetch(
`https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${auth.token}`,
},
body: formData,
signal: AbortSignal.timeout(30 * 60 * 1000),
},
);
const uploadPromise = (async () => {
try {
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
if (!response.ok) {
throw new Error(`[pyroservers] native fetch err status: ${response.status}`);
}
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
progressSubject.dispatchEvent(
new CustomEvent("progress", {
detail: {
loaded: e.loaded,
total: e.total,
progress: (e.loaded / e.total) * 100,
},
}),
);
}
});
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`));
xhr.onerror = () => reject(new Error("[pyroservers] .mrpack upload failed"));
xhr.onabort = () => reject(new Error("[pyroservers] .mrpack upload cancelled"));
xhr.ontimeout = () => reject(new Error("[pyroservers] .mrpack upload timed out"));
xhr.timeout = 30 * 60 * 1000;
xhr.open("POST", `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`);
xhr.setRequestHeader("Authorization", `Bearer ${auth.token}`);
const formData = new FormData();
formData.append("file", mrpack);
xhr.send(formData);
});
} catch (err) {
console.error("Error reinstalling from mrpack:", err);
throw err;
}
})();
return {
promise: uploadPromise,
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
progressSubject.addEventListener("progress", ((e: CustomEvent) =>
cb(e.detail)) as EventListener),
};
}
async suspend(status: boolean): Promise<void> {

View File

@@ -1,4 +1,4 @@
import { formatBytes } from "~/plugins/shorthands.js";
import { formatBytes } from "@modrinth/utils";
export const fileIsValid = (file, validationOptions) => {
const { maxSize, alertOnInvalid } = validationOptions;

View File

@@ -142,7 +142,7 @@
"
>
<nuxt-link to="/resourcepacks">
<PaintBrushIcon aria-hidden="true" /> Resource Packs
<PaintbrushIcon aria-hidden="true" /> Resource Packs
</nuxt-link>
</ButtonStyled>
<ButtonStyled
@@ -221,7 +221,7 @@
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
aria-hidden="true"
/>
<PaintBrushIcon
<PaintbrushIcon
v-else-if="
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
"
@@ -250,7 +250,7 @@
<template #mods> <BoxIcon aria-hidden="true" /> Mods </template>
<template #resourcepacks>
<PaintBrushIcon aria-hidden="true" /> Resource Packs
<PaintbrushIcon aria-hidden="true" /> Resource Packs
</template>
<template #datapacks> <BracesIcon aria-hidden="true" /> Data Packs </template>
<template #plugins> <PlugIcon aria-hidden="true" /> Plugins </template>
@@ -696,14 +696,14 @@ import {
CurrencyIcon,
BracesIcon,
GlassesIcon,
PaintBrushIcon,
PaintbrushIcon,
PackageOpenIcon,
DiscordIcon,
BlueskyIcon,
TumblrIcon,
TwitterIcon,
MastodonIcon,
GitHubIcon,
GithubIcon,
ScaleIcon,
} from "@modrinth/assets";
import {
@@ -1202,7 +1202,7 @@ const socialLinks = [
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
),
href: "https://github.com/modrinth",
icon: GitHubIcon,
icon: GithubIcon,
},
];
@@ -1211,9 +1211,9 @@ const footerLinks = [
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
links: [
{
href: "https://blog.modrinth.com",
href: "/news",
label: formatMessage(
defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
defineMessage({ id: "layout.footer.about.news", defaultMessage: "News" }),
),
},
{

View File

@@ -383,8 +383,8 @@
"layout.footer.about": {
"message": "About"
},
"layout.footer.about.blog": {
"message": "Blog"
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.careers": {
"message": "Careers"

View File

@@ -452,6 +452,16 @@
{{ formatCategory(currentPlatform) }}.
</p>
</AutomaticAccordion>
<ServersPromo
v-if="flags.showProjectPageDownloadModalServersPromo"
:link="`/servers#plan`"
@close="
() => {
flags.showProjectPageDownloadModalServersPromo = false;
saveFeatureFlags();
}
"
/>
</div>
</template>
</NewModal>
@@ -495,6 +505,64 @@
</button>
</ButtonStyled>
</div>
<Tooltip
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
theme="dismissable-prompt"
:triggers="[]"
:shown="flags.showProjectPageCreateServersTooltip"
:auto-hide="false"
placement="bottom-start"
>
<ButtonStyled size="large" circular>
<nuxt-link
v-tooltip="'Create a server'"
:to="`/servers?project=${project.id}#plan`"
@click="
() => {
flags.showProjectPageCreateServersTooltip = false;
saveFeatureFlags();
}
"
>
<ServerPlusIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<template #popper>
<div class="experimental-styles-within flex max-w-60 flex-col gap-1">
<div class="flex items-center justify-between gap-4">
<h3 class="m-0 flex items-center gap-2 text-base font-bold text-contrast">
Create a server
<TagItem
:style="{
'--_color': 'var(--color-brand)',
'--_bg-color': 'var(--color-brand-highlight)',
}"
>New</TagItem
>
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="`Don't show again`"
@click="
() => {
flags.showProjectPageCreateServersTooltip = false;
saveFeatureFlags();
}
"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
Modrinth Servers is the easiest way to play with your friends without hassle!
</p>
<p class="m-0 text-wrap text-sm font-bold text-primary">
Starting at $5<span class="text-xs"> / month</span>
</p>
</div>
</template>
</Tooltip>
<ClientOnly>
<ButtonStyled
size="large"
@@ -694,12 +762,7 @@
:tags="tags"
class="card flex-card experimental-styles-within"
/>
<!-- <AdPlaceholder
v-if="
(!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
tags.approvedStatuses.includes(project.status)
"
/> -->
<!-- <AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" /> -->
<ProjectSidebarLinks
:project="project"
:link-target="$external()"
@@ -850,12 +913,14 @@ import {
ReportIcon,
ScaleIcon,
SearchIcon,
ServerPlusIcon,
SettingsIcon,
TagsIcon,
UsersIcon,
VersionIcon,
WrenchIcon,
ModrinthIcon,
XIcon,
} from "@modrinth/assets";
import {
Avatar,
@@ -872,12 +937,22 @@ import {
ProjectSidebarLinks,
ProjectStatusBadge,
ScrollablePanel,
TagItem,
ServersPromo,
useRelativeTime,
} from "@modrinth/ui";
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
import {
formatCategory,
formatProjectType,
isRejected,
isStaff,
isUnderReview,
renderString,
} from "@modrinth/utils";
import { navigateTo } from "#app";
import dayjs from "dayjs";
import { Tooltip } from "floating-vue";
import Accordion from "~/components/ui/Accordion.vue";
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
@@ -891,6 +966,7 @@ import NavTabs from "~/components/ui/NavTabs.vue";
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
import { userCollectProject } from "~/composables/user.js";
import { reportProject } from "~/utils/report-helpers.ts";
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
const data = useNuxtApp();
const route = useNativeRoute();
@@ -1286,7 +1362,7 @@ featuredVersions.value.sort((a, b) => {
});
const projectTypeDisplay = computed(() =>
data.$formatProjectType(
formatProjectType(
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders),
),
);
@@ -1304,6 +1380,10 @@ const description = computed(
} by ${members.value.find((x) => x.is_owner)?.user?.username || "a Creator"} on Modrinth`,
);
const canCreateServerFrom = computed(() => {
return project.value.project_type === "modpack" && project.value.server_side !== "unsupported";
});
if (!route.name.startsWith("type-id-settings")) {
useSeoMeta({
title: () => title.value,
@@ -1672,4 +1752,33 @@ const navLinks = computed(() => {
display: none;
}
}
.servers-popup {
box-shadow:
0 0 12px 1px rgba(0, 175, 92, 0.6),
var(--shadow-floating);
&::before {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid var(--color-button-bg);
content: " ";
position: absolute;
top: -7px;
left: 17px;
}
&::after {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid var(--color-raised-bg);
content: " ";
position: absolute;
top: -5px;
left: 18px;
}
}
</style>

View File

@@ -50,7 +50,7 @@
Listed in search results
</li>
<li v-else>
<ExitIcon class="bad" />
<XIcon class="bad" />
Not listed in search results
</li>
<li v-if="isListed(project)">
@@ -58,11 +58,11 @@
Listed on the profiles of members
</li>
<li v-else>
<ExitIcon class="bad" />
<XIcon class="bad" />
Not listed on the profiles of members
</li>
<li v-if="isPrivate(project)">
<ExitIcon class="bad" />
<XIcon class="bad" />
Not accessible with a direct link
</li>
<li v-else>
@@ -92,7 +92,7 @@
</div>
</template>
<script setup>
import { ExitIcon, CheckIcon, IssuesIcon } from "@modrinth/assets";
import { XIcon, CheckIcon, IssuesIcon } from "@modrinth/assets";
import { Badge } from "@modrinth/ui";
import ConversationThread from "~/components/ui/thread/ConversationThread.vue";
import {

View File

@@ -104,7 +104,7 @@
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
@@ -128,7 +128,7 @@
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
@@ -239,7 +239,7 @@
</template>
<script setup>
import { formatProjectStatus } from "@modrinth/utils";
import { formatProjectStatus, formatProjectType } from "@modrinth/utils";
import { UploadIcon, SaveIcon, TrashIcon, XIcon, IssuesIcon, CheckIcon } from "@modrinth/assets";
import { Multiselect } from "vue-multiselect";
import { ConfirmModal, Avatar } from "@modrinth/ui";

View File

@@ -7,14 +7,14 @@
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
list or provide a custom license. You may also provide a custom URL to your chosen license;
otherwise, the license text will be displayed. See our
<a
href="https://blog.modrinth.com/licensing-guide/"
<nuxt-link
to="/news/article/licensing-guide/"
target="_blank"
rel="noopener"
class="text-link"
>
licensing guide
</a>
</nuxt-link>
for more information.
</p>

View File

@@ -8,7 +8,7 @@
</div>
<p>
Accurate tagging is important to help people find your
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
{{ formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
that apply.
</p>
<p v-if="project.versions.length === 0" class="known-errors">
@@ -18,25 +18,25 @@
<template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
<div class="label">
<h4>
<span class="label__title">{{ $formatCategoryHeader(header) }}</span>
<span class="label__title">{{ formatCategoryHeader(header) }}</span>
</h4>
<span class="label__description">
<template v-if="header === 'categories'">
Select all categories that reflect the themes or function of your
{{ $formatProjectType(project.project_type).toLowerCase() }}.
{{ formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'features'">
Select all of the features that your
{{ $formatProjectType(project.project_type).toLowerCase() }} makes use of.
{{ formatProjectType(project.project_type).toLowerCase() }} makes use of.
</template>
<template v-else-if="header === 'resolutions'">
Select the resolution(s) of textures in your
{{ $formatProjectType(project.project_type).toLowerCase() }}.
{{ formatProjectType(project.project_type).toLowerCase() }}.
</template>
<template v-else-if="header === 'performance impact'">
Select the realistic performance impact of your
{{ $formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
{{ $formatProjectType(project.project_type).toLowerCase() }} is configurable to
{{ formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
{{ formatProjectType(project.project_type).toLowerCase() }} is configurable to
different levels of performance impact.
</template>
</span>
@@ -46,7 +46,7 @@
v-for="category in categoryLists[header]"
:key="`category-${header}-${category.name}`"
:model-value="selectedTags.includes(category)"
:description="$formatCategory(category.name)"
:description="formatCategory(category.name)"
class="category-selector"
@update:model-value="toggleCategory(category)"
>
@@ -57,7 +57,7 @@
class="icon"
v-html="category.icon"
/>
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
<span aria-hidden="true"> {{ formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
@@ -80,7 +80,7 @@
:key="`featured-category-${category.name}`"
class="category-selector"
:model-value="featuredTags.includes(category)"
:description="$formatCategory(category.name)"
:description="formatCategory(category.name)"
:disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
@update:model-value="toggleFeaturedCategory(category)"
>
@@ -91,7 +91,7 @@
class="icon"
v-html="category.icon"
/>
<span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
<span aria-hidden="true"> {{ formatCategory(category.name) }}</span>
</div>
</Checkbox>
</div>
@@ -114,6 +114,7 @@
<script>
import { StarIcon, SaveIcon } from "@modrinth/assets";
import { formatCategory, formatCategoryHeader, formatProjectType } from "@modrinth/utils";
import Checkbox from "~/components/ui/Checkbox.vue";
export default defineNuxtComponent({
@@ -222,6 +223,9 @@ export default defineNuxtComponent({
},
},
methods: {
formatProjectType,
formatCategoryHeader,
formatCategory,
toggleCategory(category) {
if (this.selectedTags.includes(category)) {
this.selectedTags = this.selectedTags.filter((x) => x !== category);

View File

@@ -144,7 +144,7 @@
<div v-else class="input-group">
<ButtonStyled v-if="primaryFile" color="brand">
<a
v-tooltip="primaryFile.filename + ' (' + $formatBytes(primaryFile.size) + ')'"
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
:href="primaryFile.url"
@click="emit('onDownload')"
>
@@ -320,7 +320,7 @@
<FileIcon aria-hidden="true" />
<span class="filename">
<strong>{{ replaceFile.name }}</strong>
<span class="file-size">({{ $formatBytes(replaceFile.size) }})</span>
<span class="file-size">({{ formatBytes(replaceFile.size) }})</span>
</span>
<FileInput
class="iconified-button raised-button"
@@ -345,7 +345,7 @@
<FileIcon aria-hidden="true" />
<span class="filename">
<strong>{{ file.filename }}</strong>
<span class="file-size">({{ $formatBytes(file.size) }})</span>
<span class="file-size">({{ formatBytes(file.size) }})</span>
<span v-if="primaryFile.hashes.sha1 === file.hashes.sha1" class="file-type">
Primary
</span>
@@ -412,7 +412,7 @@
<FileIcon aria-hidden="true" />
<span class="filename">
<strong>{{ file.name }}</strong>
<span class="file-size">({{ $formatBytes(file.size) }})</span>
<span class="file-size">({{ formatBytes(file.size) }})</span>
</span>
<multiselect
v-if="version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x))"
@@ -533,7 +533,7 @@
)
.map((it) => it.name)
"
:custom-label="(value) => $formatCategory(value)"
:custom-label="formatCategory"
:loading="tags.loaders.length === 0"
:multiple="true"
:searchable="true"
@@ -657,6 +657,7 @@ import {
ChevronRightIcon,
} from "@modrinth/assets";
import { Multiselect } from "vue-multiselect";
import { formatBytes, formatCategory } from "@modrinth/utils";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
import { inferVersionInfo } from "~/helpers/infer.js";
import { createDataPackVersion } from "~/helpers/package.js";
@@ -962,6 +963,8 @@ export default defineNuxtComponent({
},
},
methods: {
formatBytes,
formatCategory,
async onImageUpload(file) {
const response = await useImageUpload(file, { context: "version" });

View File

@@ -153,10 +153,7 @@
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<div
v-if="flags.developerMode"
class="flex w-full items-center gap-1 text-xs text-secondary"
>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}
{{ charge.type }}
@@ -219,7 +216,6 @@ import dayjs from "dayjs";
import { products } from "~/generated/state.json";
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
const flags = useFeatureFlags();
const route = useRoute();
const data = useNuxtApp();
const vintl = useVIntl();
@@ -289,13 +285,13 @@ const selectedCharge = ref(null);
const refundType = ref("full");
const refundTypes = ref(["full", "partial", "none"]);
const refundAmount = ref(0);
const unprovision = ref(false);
const unprovision = ref(true);
function showRefundModal(charge) {
selectedCharge.value = charge;
refundType.value = "full";
refundAmount.value = 0;
unprovision.value = false;
unprovision.value = true;
refundModal.value.show();
}

File diff suppressed because one or more lines are too long

View File

@@ -248,9 +248,7 @@
</div>
</template>
</div>
<!-- <AdPlaceholder
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
/> -->
<!-- <AdPlaceholder v-if="!auth.user" /> -->
</div>
<div class="normal-page__content">
<nav class="navigation-card">
@@ -492,7 +490,6 @@ const route = useNativeRoute();
const auth = await useAuth();
const cosmetics = useCosmetics();
const tags = useTags();
const flags = useFeatureFlags();
const isEditing = ref(false);

View File

@@ -26,7 +26,7 @@
v-if="notifTypes.length > 1"
v-model="selectedType"
:items="notifTypes"
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x).replace('_', ' ') + 's')"
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x).replace('_', ' ') + 's')"
:capitalize="false"
/>
<p v-if="pending">Loading notifications...</p>
@@ -58,6 +58,7 @@
<script setup>
import { Button, Pagination, Chips } from "@modrinth/ui";
import { HistoryIcon, CheckCheckIcon } from "@modrinth/assets";
import { formatProjectType } from "@modrinth/utils";
import {
fetchExtraNotificationData,
groupNotifications,

View File

@@ -146,7 +146,7 @@
/>
<div class="push-right input-group">
<button class="iconified-button" @click="$refs.editLinksModal.hide()">
<CrossIcon />
<XIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="bulkEditLinks()">
@@ -199,8 +199,8 @@
class="square-button"
@click="updateDescending()"
>
<DescendingIcon v-if="descending" />
<AscendingIcon v-else />
<SortDescIcon v-if="descending" />
<SortAscIcon v-else />
</button>
</div>
</div>
@@ -239,7 +239,7 @@
<div>
<nuxt-link
tabindex="-1"
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}`"
>
@@ -261,7 +261,7 @@
<nuxt-link
class="hover-link wrap-as-needed"
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}`"
>
@@ -275,7 +275,7 @@
</div>
<div>
{{ $formatProjectType($getProjectTypeForUrl(project.project_type, project.loaders)) }}
{{ formatProjectType(getProjectTypeForUrl(project.project_type, project.loaders)) }}
</div>
<div>
@@ -285,7 +285,7 @@
<div>
<ButtonStyled circular>
<nuxt-link
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}/settings`"
>
@@ -306,12 +306,12 @@ import {
SettingsIcon,
TrashIcon,
PlusIcon,
XIcon as CrossIcon,
XIcon,
IssuesIcon,
EditIcon,
SaveIcon,
SortAscendingIcon as AscendingIcon,
SortDescendingIcon as DescendingIcon,
SortAscIcon,
SortDescIcon,
} from "@modrinth/assets";
import {
Avatar,
@@ -321,9 +321,11 @@ import {
ProjectStatusBadge,
commonMessages,
} from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
import Modal from "~/components/ui/Modal.vue";
import ModalCreation from "~/components/ui/ModalCreation.vue";
import { getProjectTypeForUrl } from "~/helpers/projects.js";
export default defineNuxtComponent({
components: {
@@ -335,15 +337,15 @@ export default defineNuxtComponent({
Checkbox,
IssuesIcon,
PlusIcon,
CrossIcon,
XIcon,
EditIcon,
SaveIcon,
Modal,
ModalCreation,
Multiselect,
CopyCode,
AscendingIcon,
DescendingIcon,
SortAscIcon,
SortDescIcon,
},
async setup() {
const { formatMessage } = useVIntl();
@@ -395,6 +397,8 @@ export default defineNuxtComponent({
this.DELETE_PROJECT = 1 << 7;
},
methods: {
getProjectTypeForUrl,
formatProjectType,
updateDescending() {
this.descending = !this.descending;
this.projects = this.updateSort(this.projects, this.sortBy, this.descending);

View File

@@ -76,7 +76,7 @@
</span>
<template v-if="payout.method">
<span>⋅</span>
<span>{{ $formatWallet(payout.method) }} ({{ payout.method_address }})</span>
<span>{{ formatWallet(payout.method) }} ({{ payout.method_address }})</span>
</template>
</div>
</div>
@@ -95,7 +95,7 @@
</template>
<script setup>
import { XIcon, PayPalIcon, UnknownIcon } from "@modrinth/assets";
import { capitalizeString } from "@modrinth/utils";
import { capitalizeString, formatWallet } from "@modrinth/utils";
import { Badge, Breadcrumbs, DropdownSelect } from "@modrinth/ui";
import dayjs from "dayjs";
import TremendousIcon from "~/assets/images/external/tremendous.svg?component";

View File

@@ -139,8 +139,8 @@
<template v-if="knownErrors.length === 0 && amount">
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
I acknowledge that an estimated
{{ $formatMoney(fees) }} will be deducted from the amount I receive to cover
{{ $formatWallet(selectedMethod.type) }} processing fees.
{{ formatMoney(fees) }} will be deducted from the amount I receive to cover
{{ formatWallet(selectedMethod.type) }} processing fees.
</Checkbox>
<Checkbox v-model="agreedTransfer" description="Confirm transfer">
<template v-if="selectedMethod.type === 'tremendous'">
@@ -149,7 +149,7 @@
</template>
<template v-else>
I confirm that I am initiating a transfer to the following
{{ $formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
{{ formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
</template>
</Checkbox>
<Checkbox v-model="agreedTerms" class="rewards-checkbox">
@@ -198,6 +198,7 @@ import {
} from "@modrinth/assets";
import { Chips, Checkbox, Breadcrumbs } from "@modrinth/ui";
import { all } from "iso-3166-1";
import { formatMoney, formatWallet } from "@modrinth/utils";
import VenmoIcon from "~/assets/images/external/venmo.svg?component";
const auth = await useAuth();
@@ -360,9 +361,7 @@ async function withdraw() {
text:
selectedMethod.value.type === "tremendous"
? "An email has been sent to your account with further instructions on how to redeem your payout!"
: `Payment has been sent to your ${data.$formatWallet(
selectedMethod.value.type,
)} account!`,
: `Payment has been sent to your ${formatWallet(selectedMethod.value.type)} account!`,
type: "success",
});
} catch (err) {

File diff suppressed because one or more lines are too long

View File

@@ -147,9 +147,9 @@
<tbody>
<tr v-for="item in platformRevenueData" :key="item.time">
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
<td>{{ formatMoney(item.revenue) }}</td>
<td>{{ formatMoney(item.creator_revenue) }}</td>
<td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
<td>{{ formatMoney(Number(item.revenue) + Number(item.creator_revenue)) }}</td>
<td>{{ formatMoney(Number(item.creator_revenue)) }}</td>
<td>{{ formatMoney(Number(item.revenue)) }}</td>
</tr>
</tbody>
</table>
@@ -187,6 +187,6 @@ const { data: transparencyInformation } = await useAsyncData("payout/platform_re
}),
);
const platformRevenue = transparencyInformation.value.all_time;
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
const platformRevenue = (transparencyInformation.value as any)?.all_time;
const platformRevenueData = (transparencyInformation.value as any)?.data?.slice(0, 5) ?? [];
</script>

View File

@@ -122,8 +122,8 @@
<h3>Creator Monetization Program data</h3>
<p>
When you sign up for our
<a href="https://blog.modrinth.com/p/creator-monetization-beta">
Creator Monetization Program</a
<nuxt-link to="/news/article/creator-monetization-beta">
Creator Monetization Program</nuxt-link
>
(the "CMP"), we collect:
</p>

View File

@@ -32,7 +32,7 @@
</div>
</template>
<script setup>
import { formatNumber } from "~/plugins/shorthands.js";
import { formatNumber } from "@modrinth/utils";
useHead({
title: "Staff overview - Modrinth",

View File

@@ -5,14 +5,14 @@
<Chips
v-model="projectType"
:items="projectTypes"
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x) + 's')"
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x) + 's')"
/>
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
<SortDescendingIcon />
<SortDescIcon />
Sorting by oldest
</button>
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
<SortAscendingIcon />
<SortAscIcon />
Sorting by newest
</button>
<button
@@ -56,7 +56,7 @@
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
<span class="stacked">
<span class="title">{{ project.name }}</span>
<span>{{ $formatProjectType(project.inferred_project_type) }}</span>
<span>{{ formatProjectType(project.inferred_project_type) }}</span>
</span>
</nuxt-link>
</div>
@@ -109,12 +109,12 @@ import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui
import {
UnknownIcon,
EyeIcon,
SortAscendingIcon,
SortDescendingIcon,
SortAscIcon,
SortDescIcon,
IssuesIcon,
ScaleIcon,
} from "@modrinth/assets";
import { formatProjectType } from "~/plugins/shorthands.js";
import { formatProjectType } from "@modrinth/utils";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
useHead({

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { RssIcon, GitGraphIcon } from "@modrinth/assets";
import dayjs from "dayjs";
import { articles as rawArticles } from "@modrinth/blog";
import { computed } from "vue";
import ShareArticleButtons from "~/components/ui/ShareArticleButtons.vue";
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
const config = useRuntimeConfig();
const route = useRoute();
const rawArticle = rawArticles.find((article) => article.slug === route.params.slug);
if (!rawArticle) {
throw createError({
fatal: true,
statusCode: 404,
message: "The requested article could not be found.",
});
}
const html = await rawArticle.html();
const article = computed(() => ({
...rawArticle,
path: `/news/${rawArticle.slug}`,
thumbnail: rawArticle.thumbnail
? `/news/article/${rawArticle.slug}/thumbnail.webp`
: `/news/default.webp`,
title: rawArticle.title,
summary: rawArticle.summary,
date: rawArticle.date,
html,
}));
const articleTitle = computed(() => article.value.title);
const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`);
const thumbnailPath = computed(() =>
article.value.thumbnail
? `${config.public.siteUrl}${article.value.thumbnail}`
: `${config.public.siteUrl}/news/default.jpg`,
);
const dayjsDate = computed(() => dayjs(article.value.date));
useSeoMeta({
title: () => `${articleTitle.value} - Modrinth News`,
ogTitle: () => articleTitle.value,
description: () => article.value.summary,
ogDescription: () => article.value.summary,
ogType: "article",
ogImage: () => thumbnailPath.value,
articlePublishedTime: () => dayjsDate.value.toISOString(),
twitterCard: "summary_large_image",
twitterImage: () => thumbnailPath.value,
});
</script>
<template>
<div class="page experimental-styles-within py-6">
<div
class="flex flex-wrap items-center justify-between gap-4 border-0 border-b-[1px] border-solid border-divider px-6 pb-6"
>
<nuxt-link :to="`/news`">
<h1 class="m-0 text-3xl font-extrabold hover:underline">News</h1>
</nuxt-link>
<div class="flex gap-2">
<NewsletterButton />
<ButtonStyled circular>
<a v-tooltip="`RSS feed`" aria-label="RSS feed" href="/news/feed/rss.xml" target="_blank">
<RssIcon />
</a>
</ButtonStyled>
<ButtonStyled circular icon-only>
<a v-tooltip="`Changelog`" href="/news/changelog" aria-label="Changelog">
<GitGraphIcon />
</a>
</ButtonStyled>
</div>
</div>
<article class="mt-6 flex flex-col gap-4 px-6">
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">{{ article.title }}</h2>
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
<div class="mt-auto text-sm text-secondary sm:text-base">
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}
</div>
<ShareArticleButtons :title="article.title" :url="articleUrl" />
<img
:src="article.thumbnail"
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover sm:rounded-2xl"
:alt="article.title"
/>
<div class="markdown-body" v-html="article.html" />
<h3
class="mb-0 mt-4 border-0 border-t-[1px] border-solid border-divider pt-4 text-base font-extrabold sm:text-lg"
>
Share this article
</h3>
<ShareArticleButtons :title="article.title" :url="articleUrl" />
</article>
</div>
</template>
<style lang="scss" scoped>
.page {
> *:not(.full-width-bg),
> .full-width-bg > * {
max-width: 56rem;
margin-inline: auto;
}
}
.brand-gradient-bg {
background: var(--brand-gradient-bg);
border-color: var(--brand-gradient-border);
}
@media (max-width: 640px) {
.page {
padding-top: 1rem;
padding-bottom: 1rem;
}
article {
gap: 1rem;
}
}
:deep(.markdown-body) {
h1,
h2 {
border-bottom: none;
padding: 0;
}
ul > li:not(:last-child) {
margin-bottom: 0.5rem;
}
ul,
ol {
p {
margin-bottom: 0.5rem;
}
}
ul,
ol {
strong {
color: var(--color-contrast);
font-weight: 600;
}
}
h1,
h2,
h3 {
margin-bottom: 0.25rem;
}
h1 {
font-size: 1.5rem;
@media (min-width: 640px) {
font-size: 2rem;
}
}
h2 {
font-size: 1.25rem;
@media (min-width: 640px) {
font-size: 1.5rem;
}
}
h3 {
font-size: 1.125rem;
@media (min-width: 640px) {
font-size: 1.25rem;
}
}
p {
margin-bottom: 1.25rem;
font-size: 0.875rem;
@media (min-width: 640px) {
font-size: 1rem;
}
}
a {
color: var(--color-brand);
font-weight: 600;
&:hover {
text-decoration: underline;
}
}
h1,
h2 {
a {
font-weight: 800;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
a {
color: var(--color-contrast);
}
}
img {
border: 1px solid var(--color-button-border);
border-radius: var(--radius-md);
@media (min-width: 640px) {
border-radius: var(--radius-lg);
}
}
> img,
> :has(img:first-child:last-child) {
display: flex;
justify-content: center;
}
@media (max-width: 640px) {
h1,
h2,
h3,
h4,
h5,
h6 {
margin-bottom: 0.5rem;
}
p {
margin-bottom: 1rem;
}
ul,
ol {
padding-left: 1.25rem;
}
pre {
overflow-x: auto;
font-size: 0.75rem;
}
table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
}
</style>

View File

@@ -6,6 +6,21 @@
</div>
</template>
<script lang="ts" setup>
const config = useRuntimeConfig();
useSeoMeta({
title: "Modrinth Changelog",
ogTitle: "Modrinth Changelog",
description: "Keep up-to-date on what's new with Modrinth.",
ogDescription: "Keep up-to-date on what's new with Modrinth.",
ogType: "website",
ogImage: () => `${config.public.siteUrl}/news/changelog.webp`,
twitterCard: "summary_large_image",
twitterImage: () => `${config.public.siteUrl}/news/changelog.webp`,
});
</script>
<style lang="scss" scoped>
.page {
padding: 1rem;

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