Compare commits

...

73 Commits

Author SHA1 Message Date
Josiah Glosson
52451f85b5 Attempt to fix cache issue 2025-03-12 11:10:33 -05:00
Josiah Glosson
d6c8af7ed5 Fix Labrinth not compiling on Windows due to jemalloc dependency (#3378) 2025-03-10 21:45:36 +00:00
Jai Agrawal
4dd33a2f9e Fix issues being excluded by delphi (#3374) 2025-03-09 17:15:07 -07:00
Jai Agrawal
1009695a15 Support jemalloc profiling and heap dumps (#3373) 2025-03-09 14:41:19 -07:00
Jai A
d3427375b0 fix lint 2025-03-09 13:21:15 -07:00
Jai Agrawal
5c8ed9a8ca Tracing support (#3372)
* Tracing support

* Add console subscriber

* Add console subscriber
2025-03-09 13:01:24 -07:00
Jai Agrawal
9c5d817a8a Add prom metrics for database, use redis max conn var (#3359) 2025-03-07 17:16:45 -08:00
Prospector
d51a1c47c7 Update changelog 2025-03-05 17:37:45 -08:00
Prospector
2b7378bd64 Fix moderation-side issues (#3345)
* Fix moderation-side issues by segmenting requests in review page and handling missing users in report page

* increase to 100

* 450 limit

* fine! take 1000!
2025-03-05 17:30:41 -08:00
Jai Agrawal
c1bb934fc6 Add clickhouse replication, exclude bad prom metrics (#3344) 2025-03-05 15:40:46 -08:00
Michael
0d223e3ab5 Use dumb-init as entrypoint (#3343) 2025-03-05 15:40:27 -08:00
Prospector
b704e0c8ed Update changelog 2025-03-05 12:36:57 -08:00
Prospector
79279479b1 Minor bugfixes (#3338)
* Workaround linux firefox repeat issue

* Nullcheck onShow/onHide functions
2025-03-05 12:28:48 -08:00
Prospector
ee4d7c88f1 Fix lint 2025-03-04 10:06:11 -08:00
Prospector
09023f2b49 Update changelog 2025-03-03 22:23:53 -08:00
Prospector
36cfcc2093 Admin & staff page enhancements (#3333) 2025-03-03 22:22:25 -08:00
Prospector
c2d455f166 Add random project easter egg (#3335) 2025-03-03 22:22:10 -08:00
Jai Agrawal
6859509eb5 Fix all tests (#3332) 2025-03-03 16:05:39 -08:00
Tiziano
e2de39ad83 chore: update docs deps/pkgs (#3326)
* chore: run astro upgrade cli cmd

* chore: move content config & add content loader

* chore: update pkgs

* fix: bump starlight-openapi

* fix: bump sharp

* fix: update pnpm-lock
2025-03-03 11:15:48 -08:00
Prospector
ca2307e609 Fix changelog 2025-03-02 18:45:09 -08:00
Prospector
0c58b5b83d Update changelog 2025-03-02 18:40:55 -08:00
Prospector
74a12bd606 Add scrollbar to moderation checklist when too tall (#3319) 2025-03-02 18:37:31 -08:00
Prospector
9bb9e13ee8 Add copy ID button to versions list (#3327) 2025-03-02 18:37:21 -08:00
Jai Agrawal
19787a3f51 Subpackage common -> ariadne (#3323)
* Subpackage common -> ariadne

* add common

* Remove build

* only build labrinth

* common

* set sqlx offline

* copy dirs

* Fix build
2025-03-01 20:53:43 -08:00
Josiah Glosson
650ab71a83 Commonized networking (#3310)
* Fix not being able to connect to local friends socket

* Start basic work on tunneling protocol and move some code into a common crate

* Commonize message serialization logic

* Serialize Base62Ids as u64 when human-readability is not required

* Move ActiveSockets tuple into struct

* Make CI run when rust-common is updated

CI is currently broken for labrinth, however

* Fix theseus-release.yml to reference itself correctly

* Implement Labrinth side of tunneling

* Implement non-friend part of theseus tunneling

* Implement client-side except for socket loop

* Implement the socket loop

Doesn't work though. Debugging time!

* Fix config.rs

* Fix deadlock in labrinth socket handling

* Update dockerfile

* switch to workspace prepare at root level

* Wait for connection before tunneling in playground

* Move rust-common into labrinth

* Remove rust-common references from Actions

* Revert "Update dockerfile"

This reverts commit 3caad59bb4.

* Fix Docker build

* Rebuild Theseus if common code changes

* Allow multiple connections from the same user

* Fix test building

* Move FriendSocketListening and FriendSocketStoppedListening to non-panicking TODO for now

* Make message_serialization macro take varargs for binary messages

* Improve syntax of message_serialization macro

* Remove the ability to connect to a virtual socket, and disable the ability to listen on one

* Allow the app to compile without running labrinth

* Clippy fix

* Update Rust and Clippy fix again

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2025-02-28 10:52:47 -08:00
Prospector
90def724c2 Update changelog 2025-02-25 10:11:26 -08:00
Prospector
f357275fd3 Fix upgrades being allowed when out of stock (#3307) 2025-02-25 10:07:59 -08:00
Erb3
2c2a13b587 fix(frontend): make collection summary optional in editing (#3304)
Forgot to fix it here too in #3292.
Reported by @falseresync
2025-02-25 17:53:29 +00:00
Prospector
b3a664e0d4 Improve clarity of ongoing revenue period notice (#3301)
* Improve clarity of ongoing revenue period notice

* get rid of semicolon

---------

Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
2025-02-24 19:01:41 -08:00
Prospector
3140dab99d Move Minecraft brand disclaimer to bottom of footer (#3302)
* Move Minecraft brand disclaimer to bottom of footer

* Add careers link back to footer

* Intl extract

---------

Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
2025-02-24 19:00:42 -08:00
Josiah Glosson
a74b2da147 Change app-lib io::rename to io::rename_or_move (#3251)
Also add io::is_same_disk and io::create_dir

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
2025-02-24 19:00:17 -08:00
Prospector
701fef08f8 Make debug info always expanded to aid support team, add copy button (#3282)
* Make debug info always expanded to aid support team, add copy button

* Remove testing error lol
2025-02-21 21:50:43 +00:00
Prospector
37ecf75087 Fix lint on toggle 2025-02-21 13:22:56 -08:00
Prospector
4c99e379f2 Update changelog for Feb 21st release 2025-02-21 13:20:18 -08:00
Erb3
fc2c740843 fix(frontend): make collection summary optional (#3292)
Reported by @falseresync
2025-02-21 21:09:26 +00:00
Erb3
1358336a76 refactor(ui): move Chips component to composition API + TS (#3288)
* refactor(ui): move Chips component to composition API + TS

* refactor(ui): move Chips component to composition API + TS
2025-02-21 18:59:19 +00:00
Calum H.
a02eb5445b Update Last Modified in cmp-info (#3287)
Signed-off-by: Calum H. <hendersoncal117@gmail.com>
2025-02-21 18:45:55 +00:00
Erb3
195cc9cee0 refactor(ui): move Toggle component to Composition API + TS (#3281)
* refactor(frontend): move Toggle component to Composition API + TS

**Toggle.vue**:
- Enable composition API and TS
- Added `disabled` to props
- Remove redundant `checked`
- Replace `modelValue` and `emits` with `defineModel` compiler macro

**Others**:
- Replace emit handling and `model-value` with `v-model` where simple logic was used
  - Not `FeatureFlagSettings.vue` (contained custom code on receiving emit)
  - Not `Mods.vue` (contained custom code on receiving emit)
- Remove redundant `checked` attribute

* fix(app): toggles not updating value
2025-02-21 18:43:49 +00:00
worldwidepixel
719b395b7b feat(frontend): Sort collections by creation date on user pages and dashboard (#3286) 2025-02-21 18:41:25 +00:00
Prospector
27fba4ba11 Fix padding error on revenue page (#3285) 2025-02-21 10:06:27 +00:00
Prospector
6667b620d1 Update changelog 2025-02-20 18:10:49 -08:00
Calum H.
c77f3395b2 feat(frontend): Improve revenue information (#3250)
* Improve revenue information

* Improve NET 60 period info + show next period if current period is over.

* invert period check

* %

* Finalize changes

* Cleanup

* Remove .idea

* Discard changes to .idea/discord.xml

* Discard changes to .idea/code.iml

* Discard changes to .idea/.gitignore

* Discard changes to .idea/libraries/KotlinJavaRuntime.xml

* Discard changes to .idea/vcs.xml

* Discard changes to .idea/modules.xml

* Discard changes to .idea/.gitignore

* fix lint issues

* table fix, lint fix and media sizing fix

* fix responsiveness

* Remove comment

* utc comment

* fix lint
2025-02-21 01:52:10 +00:00
Jai Agrawal
067f471766 Segment pending revenue in API response (#3283) 2025-02-20 23:07:54 +00:00
Prospector
f75d824c92 Add minimum height to content to fix space below the footer (#3279) 2025-02-20 22:38:28 +00:00
Prospector
c4f582e35b Fixed proof form styling in checklist (#3278) 2025-02-20 22:38:10 +00:00
Prospector
a8727d9a68 Updated changelog with Feb 19 update 2025-02-19 22:14:27 -08:00
Prospector
423dae5208 2025 :) 2025-02-19 22:10:15 -08:00
Prospector
dc7d8d6018 Add settings button to navbar 2025-02-19 22:08:16 -08:00
Prospector
db09ded836 New footer (#2988)
* Update footer to new design

* Update footer with changelog and security notice

* Move mastodon icon

* Make full-width instead of card

* Intl extract, lint
2025-02-19 22:01:29 -08:00
Prospector
df33ff7f60 Servers Marketing: Add Dallas location to globe, change copy to refer to states (#3272) 2025-02-19 22:01:11 -08:00
Prospector
ca63c09a0d Enhance moderation checklist (#3273) 2025-02-19 22:00:52 -08:00
Erb3
9c2cd868a7 refactor(frontend): Project page composition API + TS (#3245)
* refactor(frontend): move project description to composition API + TS

* refactor(frontend): rename to `patchRequestPayload` for consistency

* chore: lint
2025-02-19 18:49:07 +00:00
Jeffrey Daniel
f6d64e8fde Fix MessageBanner padding (#3271)
The MessageBanner padding that appears when a project is archived only has padding on the bottom and not the top. This means that there is no visual gap and it does follow the visual style of the rest of the website. I have fixed it.
2025-02-19 00:39:08 +00:00
Prospector
253e64884a Updated changelog with Servers Feb 18 release 2025-02-18 14:26:35 -08:00
Evan Song
a88593fec5 Modrinth Servers February Release: Bug Fix Round 1 (#3267)
* chore(pyroservers): attempt better error propogation

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(pyroservers): introduce deferred modules

* fix(pyroservers): synchronize server icon processing

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor: server action buttons

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: bring back skeleton

* fix(startup): populate values on refresh

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: properly refresh network

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: do not open backup settings modal if fetch failed

* fix(platform): only clear selected loader version if selecting a different loader

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: parse links in console log

* fix: attempt to mitigate power button state flash

Signed-off-by: Evan Song <theevansong@gmail.com>

* Revert "fix: attempt to mitigate power button state flash"

This reverts commit 3ba5c0b4f7.

* refactor: error accumulation builder in PyroServersFetch

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: sentence case

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(files): await deferred fs

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: startup border

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: prevent suspended server errors from being overwritten

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: add server id copy button to suspended server listing

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: refresh behavior

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: behavior of server icon in options

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: prevent error inspector failures from destroying the page

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: remove nexttick wrapper

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: ensure file edit gets initted due to deferred module

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor: prevent module errors from breaking the layout

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

---------

Signed-off-by: Evan Song <theevansong@gmail.com>
2025-02-18 22:17:50 +00:00
Jai A
6c4548a303 add price id to active servers route 2025-02-16 21:42:16 -08:00
Prospector
4851f18079 Updated changelog 2025-02-16 19:04:11 -08:00
Jakob
bbb917c405 Fixed lack of space around verffication name in ConfirmModal (#3258)
Signed-off-by: Jakob <minenash@protonmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-02-16 18:59:26 -08:00
Prospector
81d34dfa86 Add stock checking to server upgrades (#3270)
* Add stock checking to server upgrades

* Fix route
2025-02-16 18:58:09 -08:00
Jai Agrawal
31723a2d3c Fix prorations not updating open charge (#3265) 2025-02-14 22:53:45 -08:00
Jai Agrawal
975427b319 Allow admins to view user email (#3261) 2025-02-13 17:18:03 -08:00
Prospector
d9983debce Change out of stock URL to discord (#3256)
* Change out of stock URL to discord, switch from router links to normal anchor tags

* Update changelog
2025-02-12 19:09:11 -08:00
Prospector
6a12ef0e2c Fix lint 2025-02-12 19:08:08 -08:00
Prospector
56ba342346 Initial servers upgrades frontend (#3219)
* Initial servers upgrades frontend

* Fix error when purchasing non-custom servers

* fix backend

* Fix comment

---------

Signed-off-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Jai Agrawal <18202329+Geometrically@users.noreply.github.com>
2025-02-12 18:22:49 -08:00
Prospector
6d810a421a Servers marketing enhancements (#3252)
* feat: locations page + stock callouts

* feat: misalligned but spirits there!!

* fix readability on colors on globe

* Enhancements to globe

* Fix out of stock indicator styling

* Start globe near US and slow speed

* Remove debug statement

* Switch from capacity to stock API

* Make custom use its own stock checker

* Fix lint, add changelog entries

---------

Co-authored-by: Elizabeth <checksum@pyro.host>
Co-authored-by: Lio <git@lio.cat>
2025-02-12 12:06:51 -08:00
Prospector
098519dea1 Fix changelog timestamps being an hour off 2025-02-12 10:42:03 -08:00
Prospector
7183b3d761 Fix some changelog issues, and broader mobile padding issues (#3246) 2025-02-11 12:46:34 -08:00
Prospector
0ac49d846f Add project issues link to report form if it exists (#3215) 2025-02-11 11:07:23 -08:00
Prospector
cade2c182c Add changelog page to website (#3242)
* Add changelog page to website

* Add pages for individual changelog entries that can be linked to

* Handle first case for individual page

* Add some more changelog entries, improve some spacing
2025-02-11 08:50:27 -08:00
Erb3
affeec82f0 License UI redesign + composition API (#3225)
* refactor(frontend): revamp license page

- Add more understandable UI
  - Field titles
  - Field description
- Use more semantically correct elements
  - Make paragraph not a label
- Rephrase some parts
- Fields no longer jump around
- Split SPDX-identifier and license name into two seperate fields, for readability
- Sort imports
- fmt

* feat(frontend): encourage license URL on custom license

* refactor(frontend): license page to composition + ts

- Move to Vue composition API
- Move to TypeScript
- Move away from vue-multiselect to the dropdown component
- Use `formatProjectType` function for typesafety
- Remove unused form error highlighting code
- Creating typings for built-in licenses
- Move standard licenses to licenses.ts util
  - There are other license-related utils I want to move there eventually
- Fix typo in Project license type definition

* chore(frontend): fmt

* chore(frontend): fmt

* feat(frontend): require URL and name for custom license

* refactor(frontend): give license or-later checkbox own row

* chore(frontend): fmt
2025-02-10 08:37:49 -08:00
Evan Song
a75538c093 Modrinth Servers Mega Features & Bug Fix-a-thon (#3222)
* fix(content): changing mod versions works again

* chore(assets): update pyro logo

* fix(properties): deprecate fetchconfigfile

* Revert "fix(content): changing mod versions works again"

This reverts commit d7c0d1196f.

* feat(files): ability to sort via column click

* chore(startup): update clunky wording

* feat(serverListing): server icons SSR friendly

* fix(servers): if archon fails, display err in listing

* chore(serverlisting): use pyroserver hook to init icon

* chore(servers): much more graceful reinstall

* fix(servers): tw warn

* fix(platform): correctly react when pack reinstalled

* fix(serversroot): explicitly import navigateTo

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(serverlabels): show skeleton instead of hiding

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(platform): install-aware controls

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor!(platform): rewrite platform page

* fix(platform): regression in autoselecting loader

* chore(platform): prefer version over project modification date

* fix(platform): permanent hang after initial mount

* chore(platform): do not silently fail and hang if modpack fails loading

* oops: remove hardcoded error causer

* fix(platform): switch modpack btn while installing doesnt need class

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(platform): adjust styling in version modal

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(platform): prevent changing project card style

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor(pyrodropdown): rewrite

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(pyrodropdown): do nopt use deprecated substr

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(network): sentence case

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor(terminal): initial batch

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): fulllog over fullscreen

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): fullscreen conflict with body scroll

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(terminal): init drag select

* feat(terminal): shift click support

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(terminal): double lines limit

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(terminal): copy button

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(terminal): protip style

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(terminal): improve styles

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(terminal): regex search

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(terminal): move icons to icons dir

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(terminal): improve drag select autoscroll inertia

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): cancel selection on right click

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): progblur and stb btn disappearing

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor(serverstats): power efficiency

* fix(subdomainlabel): correct tooltip terminology

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(preferences): users hide subdomain label

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(servers): clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(terminal): deselect lines on escape

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(serversidebar): type err

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(fileitem): vue server render type

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): disable pointer events on lines if scrolling

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): search result counts style

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): plural

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(terminal): clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(terminal): view selection

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(terminal): show actively selected lines in scrollbar

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminallog): btn color

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(gamelabel): align to text

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(gamelabel): align to text

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(listing): remove deadcode

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(serverlisting): deprecated process.server

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(platform): correctly disable button

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(backups): do not allow backup creation during server installation

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(platform): flush stale currentversion data on successful install

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(gamelabel): fix gap

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(network): vaporize uppercase

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(info): vaporize uppercase

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(backups): style unification

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(backups): finalize style change

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(servers): catch pyro servers fetch errors during ssr

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(serverstats): ram as bytes graph now works

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(platform): unify attempts and refresh interval

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): input

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(servers): installing ticket + update available notice back in platform

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(terminal): dont add bg to scroll track

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): preserve whitespace

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(serversroot): unnest blurred icon query

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(serverstats): clamp memory usage to 100% no matter what

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(terminal): allow copy of single lines, show btn

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(terminal): animate copy>view transition

Signed-off-by: Evan Song <theevansong@gmail.com>

* init: search improvements

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: lint

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: change log modal title

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: hide fullscreen when selecting and cancel selection on clickout

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor(terminal): more reliable jumpToLine

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: search results separator

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: remove buggy isScrollable check

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: style

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor: correctly store pos to make jump reliable

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: disparity between search/log dragselect

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: prevent propagation of click events when clicking on jump btn

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: switch selection strategies depending on terminal mode

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: smarter esc handling

Signed-off-by: Evan Song <theevansong@gmail.com>

* finalize

Signed-off-by: Evan Song <theevansong@gmail.com>

* run fix

* fix: ensure lines between cannot be selected

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: increase initial log batch to 256

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): click on scroll track should take user to new scroll position

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): update aria label for view selected logs btn

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

---------

Signed-off-by: Evan Song <theevansong@gmail.com>
2025-02-10 07:39:13 -08:00
he3als
037cc86c1f Server Content Tab Fixes & Improvements (#3230)
* fix cancel button on edit modal

* make hardcoded mod text dynamic for plugins

* fix files path when clicking an external plugin

* fix plugins path for file uploads

* improve friendly mod name logic

* fix toggling plugins

* update pyroServers content definitions

* install then remove for changing version

Reinstall isn't currently implemented properly

* make the edit dialog pretty

* make new admonition component

* fix warning admonition colour

* new edit version modal

* cleanup

* make latest version default

* final touches

* lint
2025-02-08 10:31:38 -08:00
Jai Agrawal
1e8d550e96 Format traces as bullet list (MOD-32) (#3220)
* Format traces as bullet list

* Fix fmt
2025-02-06 13:23:42 -08:00
231 changed files with 12887 additions and 6423 deletions

View File

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

View File

@@ -38,8 +38,10 @@ jobs:
- name: Build and push - name: Build and push
id: docker_build id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
env:
SQLX_OFFLINE: true
with: with:
context: ./apps/labrinth file: ./apps/labrinth/Dockerfile
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }} labels: ${{ steps.docker_meta.outputs.labels }}

View File

@@ -6,9 +6,11 @@ on:
tags: tags:
- 'v*' - 'v*'
paths: paths:
- .github/workflows/app-release.yml - .github/workflows/theseus-release.yml
- 'apps/app/**' - 'apps/app/**'
- 'apps/app-frontend/**' - 'apps/app-frontend/**'
- 'apps/labrinth/src/common/**'
- 'apps/labrinth/Cargo.toml'
- 'packages/app-lib/**' - 'packages/app-lib/**'
- 'packages/app-macros/**' - 'packages/app-macros/**'
- 'packages/assets/**' - 'packages/assets/**'

4
.idea/code.iml generated
View File

@@ -10,9 +10,11 @@
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" /> <sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

687
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ members = [
'./apps/labrinth', './apps/labrinth',
'./apps/daedalus_client', './apps/daedalus_client',
'./packages/daedalus', './packages/daedalus',
'./packages/ariadne',
] ]
# Optimize for speed and reduce size on release builds # Optimize for speed and reduce size on release builds
@@ -21,4 +22,4 @@ strip = true # Remove debug symbols
opt-level = 3 opt-level = 3
[patch.crates-io] [patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" } wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }

View File

@@ -1,7 +1,16 @@
<script setup> <script setup>
import { XIcon, HammerIcon, LogInIcon, UpdatedIcon } from '@modrinth/assets' import {
CheckIcon,
DropdownIcon,
XIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
CopyIcon,
} from '@modrinth/assets'
import { ChatIcon } from '@/assets/icons' import { ChatIcon } from '@/assets/icons'
import { ref } from 'vue' import { ButtonStyled, Collapsible } from '@modrinth/ui'
import { ref, computed } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js' import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
@@ -13,6 +22,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const errorModal = ref() const errorModal = ref()
const error = ref() const error = ref()
const closable = ref(true) const closable = ref(true)
const errorCollapsed = ref(false)
const title = ref('An error occurred') const title = ref('An error occurred')
const errorType = ref('unknown') const errorType = ref('unknown')
@@ -118,6 +128,26 @@ async function repairInstance() {
} }
loadingRepair.value = false loadingRepair.value = false
} }
const hasDebugInfo = computed(
() =>
errorType.value === 'directory_move' ||
errorType.value === 'minecraft_auth' ||
errorType.value === 'state_init' ||
errorType.value === 'no_loader_version',
)
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
const copied = ref(false)
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script> </script>
<template> <template>
@@ -244,16 +274,9 @@ async function repairInstance() {
</div> </div>
</template> </template>
<template v-else> <template v-else>
{{ error.message ?? error }} {{ debugInfo }}
</template> </template>
<template <template v-if="hasDebugInfo">
v-if="
errorType === 'directory_move' ||
errorType === 'minecraft_auth' ||
errorType === 'state_init' ||
errorType === 'no_loader_version'
"
>
<hr /> <hr />
<p> <p>
If nothing is working and you need help, visit If nothing is working and you need help, visit
@@ -261,16 +284,39 @@ async function repairInstance() {
and start a chat using the widget in the bottom right and we will be more than happy to and start a chat using the widget in the bottom right and we will be more than happy to
assist! Make sure to provide the following debug information to the agent: assist! Make sure to provide the following debug information to the agent:
</p> </p>
<details>
<summary>Debug information</summary>
{{ error.message ?? error }}
</details>
</template> </template>
</div> </div>
<div class="input-group push-right"> <div class="flex items-center gap-2">
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a> <ButtonStyled>
<button v-if="closable" class="btn" @click="errorModal.hide()"><XIcon /> Close</button> <a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
</ButtonStyled>
<ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled>
<ButtonStyled v-if="hasDebugInfo">
<button :disabled="copied" @click="copyToClipboard(debugInfo)">
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
<template v-else> <CopyIcon /> Copy debug info </template>
</button>
</ButtonStyled>
</div> </div>
<template v-if="hasDebugInfo">
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
@click="errorCollapsed = !errorCollapsed"
>
<span class="text-contrast font-extrabold m-0">Debug information:</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !errorCollapsed }"
/>
</button>
<Collapsible :collapsed="errorCollapsed">
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
</Collapsible>
</div>
</template>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

@@ -5,7 +5,7 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings' import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings } from '../../../helpers/types' import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -114,7 +114,6 @@ const messages = defineMessages({
<Toggle <Toggle
id="fullscreen" id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen" :model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:checked="fullscreenSetting"
:disabled="!overrideWindowSettings" :disabled="!overrideWindowSettings"
@update:model-value=" @update:model-value="
(e) => { (e) => {

View File

@@ -43,7 +43,7 @@ function onModalHide() {
if (props.showAdOnClose) { if (props.showAdOnClose) {
show_ads_window() show_ads_window()
} }
props.onHide() props.onHide?.()
} }
</script> </script>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Toggle, ThemeSelector, TeleportDropdownMenu } from '@modrinth/ui' import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings' import { get, set } from '@/helpers/settings'
import { watch, ref } from 'vue' import { ref, watch } from 'vue'
import { getOS } from '@/helpers/utils' import { getOS } from '@/helpers/utils'
const themeStore = useTheming() const themeStore = useTheming()
@@ -46,7 +46,6 @@ watch(
<Toggle <Toggle
id="advanced-rendering" id="advanced-rendering"
:model-value="themeStore.advancedRendering" :model-value="themeStore.advancedRendering"
:checked="themeStore.advancedRendering"
@update:model-value=" @update:model-value="
(e) => { (e) => {
themeStore.advancedRendering = e themeStore.advancedRendering = e
@@ -61,16 +60,7 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p> <p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div> </div>
<Toggle <Toggle id="native-decorations" v-model="settings.native_decorations" />
id="native-decorations"
:model-value="settings.native_decorations"
:checked="settings.native_decorations"
@update:model-value="
(e) => {
settings.native_decorations = e
}
"
/>
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
@@ -78,16 +68,7 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p> <p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div> </div>
<Toggle <Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
id="minimize-launcher"
:model-value="settings.hide_on_process_start"
:checked="settings.hide_on_process_start"
@update:model-value="
(e) => {
settings.hide_on_process_start = e
}
"
/>
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
@@ -111,7 +92,6 @@ watch(
<Toggle <Toggle
id="toggle-sidebar" id="toggle-sidebar"
:model-value="settings.toggle_sidebar" :model-value="settings.toggle_sidebar"
:checked="settings.toggle_sidebar"
@update:model-value=" @update:model-value="
(e) => { (e) => {
settings.toggle_sidebar = e settings.toggle_sidebar = e

View File

@@ -57,16 +57,7 @@ watch(
</p> </p>
</div> </div>
<Toggle <Toggle id="fullscreen" v-model="settings.force_fullscreen" />
id="fullscreen"
:model-value="settings.force_fullscreen"
:checked="settings.force_fullscreen"
@update:model-value="
(e) => {
settings.force_fullscreen = e
}
"
/>
</div> </div>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">

View File

@@ -37,7 +37,6 @@ watch(
<Toggle <Toggle
id="advanced-rendering" id="advanced-rendering"
:model-value="getStoreValue(option)" :model-value="getStoreValue(option)"
:checked="getStoreValue(option)"
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])" @update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
/> />
</div> </div>

View File

@@ -30,16 +30,7 @@ watch(
option, you opt out and ads will no longer be shown based on your interests. option, you opt out and ads will no longer be shown based on your interests.
</p> </p>
</div> </div>
<Toggle <Toggle id="personalized-ads" v-model="settings.personalized_ads" />
id="personalized-ads"
:model-value="settings.personalized_ads"
:checked="settings.personalized_ads"
@update:model-value="
(e) => {
settings.personalized_ads = e
}
"
/>
</div> </div>
<div class="mt-4 flex items-center justify-between gap-4"> <div class="mt-4 flex items-center justify-between gap-4">
@@ -51,16 +42,7 @@ watch(
longer be collected. longer be collected.
</p> </p>
</div> </div>
<Toggle <Toggle id="opt-out-analytics" v-model="settings.telemetry" />
id="opt-out-analytics"
:model-value="settings.telemetry"
:checked="settings.telemetry"
@update:model-value="
(e) => {
settings.telemetry = e
}
"
/>
</div> </div>
<div class="mt-4 flex items-center justify-between gap-4"> <div class="mt-4 flex items-center justify-between gap-4">
@@ -75,10 +57,6 @@ watch(
as those added by mods. (app restart required to take effect) as those added by mods. (app restart required to take effect)
</p> </p>
</div> </div>
<Toggle <Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
id="disable-discord-rpc"
v-model="settings.discord_rpc"
:checked="settings.discord_rpc"
/>
</div> </div>
</template> </template>

View File

@@ -179,7 +179,6 @@
<Toggle <Toggle
class="!mx-2" class="!mx-2"
:model-value="!item.data.disabled" :model-value="!item.data.disabled"
:checked="!item.data.disabled"
@update:model-value="toggleDisableMod(item.data)" @update:model-value="toggleDisableMod(item.data)"
/> />
<ButtonStyled type="transparent" circular> <ButtonStyled type="transparent" circular>

View File

@@ -0,0 +1,2 @@
[env]
SQLX_OFFLINE = "true"

View File

@@ -3,9 +3,9 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
use std::time::Duration;
use theseus::prelude::*; use theseus::prelude::*;
use tokio::signal::ctrl_c;
use theseus::profile::create::profile_create;
// A simple Rust implementation of the authentication run // A simple Rust implementation of the authentication run
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend) // 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
@@ -41,54 +41,21 @@ async fn main() -> theseus::Result<()> {
// Initialize state // Initialize state
State::init().await?; State::init().await?;
if minecraft_auth::users().await?.is_empty() { loop {
println!("No users found, authenticating."); if State::get().await?.friends_socket.is_connected().await {
authenticate_run().await?; // could take credentials from here direct, but also deposited in state users break;
}
//
// st.settings
// .write()
// .await
// .java_globals
// .insert(JAVA_8_KEY.to_string(), check_jre(path).await?.unwrap());
// Clear profiles
println!("Clearing profiles.");
{
let h = profile::list().await?;
for profile in h.into_iter() {
profile::remove(&profile.path).await?;
} }
tokio::time::sleep(Duration::from_millis(500)).await;
} }
println!("Creating/adding profile."); tracing::info!("Starting host");
let name = "Example".to_string(); let socket = State::get().await?.friends_socket.open_port(25565).await?;
let game_version = "1.16.1".to_string(); tracing::info!("Running host on socket {}", socket.socket_id());
let modloader = ModLoader::Forge;
let loader_version = "stable".to_string();
let profile_path = profile_create( ctrl_c().await?;
name, tracing::info!("Stopping host");
game_version, socket.shutdown().await?;
modloader,
Some(loader_version),
None,
None,
None,
)
.await?;
println!("running");
// Run a profile, running minecraft and store the RwLock to the process
let process = profile::run(&profile_path).await?;
println!("Minecraft UUID: {}", process.uuid);
println!("All running process UUID {:?}", process::get_all().await?);
// hold the lock to the process until it ends
println!("Waiting for process to end...");
process::wait_for(process.uuid).await?;
Ok(()) Ok(())
} }

View File

@@ -0,0 +1,2 @@
[env]
SQLX_OFFLINE = "true"

View File

@@ -10,12 +10,12 @@
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.3", "@astrojs/check": "^0.9.4",
"@astrojs/starlight": "^0.26.3", "@astrojs/starlight": "^0.32.2",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"astro": "^4.10.2", "astro": "^5.4.1",
"sharp": "^0.32.5", "sharp": "^0.33.5",
"starlight-openapi": "^0.7.0", "starlight-openapi": "^0.14.0",
"typescript": "^5.5.4" "typescript": "^5.8.2"
} }
} }

View File

@@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@@ -1,6 +0,0 @@
import { defineCollection } from 'astro:content'
import { docsSchema } from '@astrojs/starlight/schema'
export const collections = {
docs: defineCollection({ schema: docsSchema() }),
}

View File

@@ -57,6 +57,8 @@
"pinia": "^2.1.7", "pinia": "^2.1.7",
"qrcode.vue": "^3.4.0", "qrcode.vue": "^3.4.0",
"semver": "^7.5.4", "semver": "^7.5.4",
"three": "^0.172.0",
"@types/three": "^0.172.0",
"vue-multiselect": "3.0.0-alpha.2", "vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10", "vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4", "vue3-ace-editor": "^2.2.4",

View File

@@ -133,6 +133,21 @@
"sidebar" "sidebar"
/ 100%; / 100%;
.normal-page__ultimate-sidebar {
grid-area: ultimate-sidebar;
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 100;
max-width: calc(100% - 2rem);
max-height: calc(100vh - 2rem);
overflow-y: auto;
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
&.sidebar { &.sidebar {
grid-template: grid-template:
@@ -156,6 +171,45 @@
} }
} }
@media screen and (min-width: 1400px) {
&.ultimate-sidebar {
max-width: calc(80rem + 0.75rem + 600px);
grid-template:
"header header ultimate-sidebar" auto
"content sidebar ultimate-sidebar" auto
"content dummy ultimate-sidebar" 1fr
/ 1fr 18.75rem auto;
.normal-page__header {
max-width: 80rem;
}
.normal-page__ultimate-sidebar {
position: sticky;
top: 4.5rem;
bottom: unset;
right: unset;
z-index: unset;
align-self: start;
display: flex;
height: calc(100vh - 4.5rem * 2);
> div {
box-shadow: none;
}
}
&.alt-layout {
grid-template:
"ultimate-sidebar header header" auto
"ultimate-sidebar sidebar content" auto
"ultimate-sidebar dummy content" 1fr
/ auto 18.75rem 1fr;
}
}
}
.normal-page__sidebar { .normal-page__sidebar {
grid-area: sidebar; grid-area: sidebar;
} }

View File

@@ -19,10 +19,7 @@
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1"> <label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> <span class="text-lg font-semibold text-contrast"> Summary </span>
Summary
<span class="text-brand-red">*</span>
</span>
<span>A sentence or two that describes your collection.</span> <span>A sentence or two that describes your collection.</span>
</label> </label>
<div class="textarea-wrapper"> <div class="textarea-wrapper">
@@ -52,8 +49,8 @@
</NewModal> </NewModal>
</template> </template>
<script setup> <script setup>
import { XIcon, PlusIcon } from "@modrinth/assets"; import { PlusIcon, XIcon } from "@modrinth/assets";
import { NewModal, ButtonStyled } from "@modrinth/ui"; import { ButtonStyled, NewModal } from "@modrinth/ui";
const router = useNativeRouter(); const router = useNativeRouter();
@@ -78,7 +75,7 @@ async function create() {
method: "POST", method: "POST",
body: { body: {
name: name.value.trim(), name: name.value.trim(),
description: description.value.trim(), description: description.value.trim() || undefined,
projects: props.projectIds, projects: props.projectIds,
}, },
apiVersion: 3, apiVersion: 3,

View File

@@ -1,329 +1,366 @@
<template> <template>
<div class="card moderation-checklist"> <div
<h1>Moderation checklist</h1> class="moderation-checklist flex w-[600px] max-w-full flex-col rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
<div v-if="done"> :class="collapsed ? `sm:max-w-[300px]` : 'sm:max-w-[600px]'"
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p> >
<div class="flex grow-0 items-center gap-2">
<h1 class="m-0 mr-auto flex items-center gap-2 text-2xl font-extrabold text-contrast">
<ScaleIcon class="text-orange" /> Moderation
</h1>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button v-tooltip="`Exit moderation`" @click="exitModeration">
<CrossIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button v-tooltip="collapsed ? `Expand` : `Collapse`" @click="emit('toggleCollapsed')">
<DropdownIcon class="transition-transform" :class="{ 'rotate-180': collapsed }" />
</button>
</ButtonStyled>
</div> </div>
<div v-else-if="generatedMessage"> <Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed">
<p> <div class="my-4 h-[1px] w-full bg-divider" />
Enter your moderation message here. Remember to check the Moderation tab to answer any <div v-if="done">
questions an author might have! <p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
</p>
<div class="markdown-editor-spacing">
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
</div> </div>
</div> <div v-else-if="generatedMessage">
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'"> <p>
<h2 v-if="modPackData"> Enter your moderation message here. Remember to check the Moderation tab to answer any
Modpack permissions questions an author might have!
<template v-if="modPackIndex + 1 <= modPackData.length"> </p>
({{ modPackIndex + 1 }} / {{ modPackData.length }}) <div class="markdown-editor-spacing">
</template> <MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
</h2> </div>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p>
</div> </div>
<div v-else-if="!modPackData[modPackIndex]"> <div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
<p>All permission checks complete!</p> <h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
<div class="input-group modpack-buttons"> Modpack permissions
<button class="btn" @click="modPackIndex -= 1"> <template v-if="modPackIndex + 1 <= modPackData.length">
<LeftArrowIcon aria-hidden="true" /> ({{ modPackIndex + 1 }} / {{ modPackData.length }})
Previous </template>
</button> </h2>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p>
</div>
<div v-else-if="!modPackData[modPackIndex]">
<p>All permission checks complete!</p>
<div class="input-group modpack-buttons">
<ButtonStyled>
<button @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
</div>
</div>
<div v-else>
<div v-if="modPackData[modPackIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
<div
v-if="modPackData[modPackIndex].status !== 'unidentified'"
class="flex flex-col gap-1"
>
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="modPackData[modPackIndex].proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="modPackData[modPackIndex].url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="modPackData[modPackIndex].title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
/>
</div>
</div>
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
:href="modPackData[modPackIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[modPackIndex].url }}</a
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
"
>
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in filePermissionTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].approved === option.id,
}"
@click="modPackData[modPackIndex].approved = option.id"
>
{{ option.name }}
</button>
</div>
</div>
<div class="mt-4 flex gap-2">
<ButtonStyled>
<button :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
<ButtonStyled color="blue">
<button :disabled="!modPackData[modPackIndex].status" @click="modPackIndex += 1">
<RightArrowIcon aria-hidden="true" />
Next project
</button>
</ButtonStyled>
</div>
</div> </div>
</div> </div>
<div v-else> <div v-else>
<div v-if="modPackData[modPackIndex].type === 'unknown'"> <h2 class="m-0 mb-2 text-lg font-extrabold">{{ steps[currentStepIndex].question }}</h2>
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p> <template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
<div class="input-group"> <strong>Guidance:</strong>
<button <ul class="mb-3 mt-2 leading-tight">
v-for="(option, index) in fileApprovalTypes" <li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
:key="index" {{ rule }}
class="btn" </li>
:class="{ </ul>
'option-selected': modPackData[modPackIndex].status === option.id, </template>
}" <template
@click="modPackData[modPackIndex].status = option.id" v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
> >
{{ option.name }} <strong>Reject things like:</strong>
</button> <ul class="mb-3 mt-2 leading-tight">
</div> <li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
<template v-if="modPackData[modPackIndex].status !== 'unidentified'"> {{ example }}
<div class="universal-labels"></div> </li>
<label for="proof"> </ul>
<span class="label__title">Proof</span> </template>
</label> <template
<input v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
id="proof" >
v-model="modPackData[modPackIndex].proof" <strong>Exceptions:</strong>
type="text" <ul class="mb-3 mt-2 leading-tight">
autocomplete="off" <li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
placeholder="Enter proof of status..." {{ exception }}
/> </li>
<label for="link"> </ul>
<span class="label__title">Link</span> </template>
</label> <p v-if="steps[currentStepIndex].id === 'title'">
<input <strong>Title:</strong> {{ project.title }}
id="link" </p>
v-model="modPackData[modPackIndex].url" <p v-if="steps[currentStepIndex].id === 'slug'">
type="text" <strong>Slug:</strong> {{ project.slug }}
autocomplete="off" </p>
placeholder="Enter link of project..." <p v-if="steps[currentStepIndex].id === 'summary'">
/> <strong>Summary:</strong> {{ project.description }}
<label for="title"> </p>
<span class="label__title">Title</span> <p v-if="steps[currentStepIndex].id === 'links'">
</label> <template v-if="project.issues_url">
<input <strong>Issues: </strong>
id="title" <a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
v-model="modPackData[modPackIndex].title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
/>
</template> </template>
</div> <template v-if="project.source_url">
<div v-else-if="modPackData[modPackIndex].type === 'flame'"> <strong>Source: </strong>
<p> <a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
What is the approval type of {{ modPackData[modPackIndex].title }} (<a </template>
:href="modPackData[modPackIndex].url" <template v-if="project.wiki_url">
target="_blank" <strong>Wiki: </strong>
class="text-link" <a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
>{{ modPackData[modPackIndex].url }}</a </template>
>? <template v-if="project.discord_url">
</p> <strong>Discord: </strong>
<div class="input-group"> <a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
<button <br />
v-for="(option, index) in fileApprovalTypes" </template>
:key="index" <template v-for="(donation, index) in project.donation_urls" :key="index">
class="btn" <strong>{{ donation.platform }}: </strong>
:class="{ <a class="text-link" :href="donation.url">{{ donation.url }}</a>
'option-selected': modPackData[modPackIndex].status === option.id, <br />
}" </template>
@click="modPackData[modPackIndex].status = option.id" </p>
> <p v-if="steps[currentStepIndex].id === 'categories'">
{{ option.name }} <strong>Categories:</strong>
</button> <Categories
</div> :categories="project.categories.concat(project.additional_categories)"
:type="project.actualProjectType"
class="categories"
/>
</p>
<p v-if="steps[currentStepIndex].id === 'side-types'">
<strong>Client side:</strong> {{ project.client_side }} <br />
<strong>Server side:</strong> {{ project.server_side }}
</p>
<div class="options input-group">
<button
v-for="(option, index) in steps[currentStepIndex].options"
:key="index"
class="btn"
:class="{
'option-selected':
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
}"
@click="toggleOption(steps[currentStepIndex].id, option)"
>
{{ option.name }}
</button>
</div> </div>
<div <div
v-if=" v-if="
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status) selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].length > 0
" "
class="inputs universal-labels"
> >
<p v-if="modPackData[modPackIndex].status === 'unidentified'"> <div
Does this project provide identification and permission for v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
<strong>{{ modPackData[modPackIndex].file_name }}</strong (x) => x.fillers && x.fillers.length > 0,
>? )"
</p> :key="index"
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'"> >
Does this project provide attribution for <div v-for="(filler, idx) in option.fillers" :key="idx">
<strong>{{ modPackData[modPackIndex].file_name }}</strong <label :for="filler.id">
>? <span class="label__title">
</p> {{ filler.question }}
<p v-else> <span v-if="filler.required" class="required">*</span>
Does this project provide proof of permission for </span>
<strong>{{ modPackData[modPackIndex].file_name }}</strong </label>
>? <div v-if="filler.large" class="markdown-editor-spacing">
</p> <MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
<div class="input-group"> </div>
<button <input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
v-for="(option, index) in filePermissionTypes" </div>
:key="index" </div>
class="btn" </div>
:class="{ </div>
'option-selected': modPackData[modPackIndex].approved === option.id, <div class="mt-auto">
}" <div
@click="modPackData[modPackIndex].approved = option.id" class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
> >
{{ option.name }} <div class="flex items-center gap-2">
<ButtonStyled v-if="!done">
<button aria-label="Skip" @click="goToNextProject">
<ExitIcon aria-hidden="true" />
<template v-if="futureProjects.length > 0">Skip</template>
<template v-else>Exit</template>
</button>
</ButtonStyled>
<ButtonStyled v-if="currentStepIndex > 0">
<button @click="previousPage() && !done">
<LeftArrowIcon aria-hidden="true" /> Previous
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled v-if="currentStepIndex < steps.length - 1 && !done" color="brand">
<button @click="nextPage()"><RightArrowIcon aria-hidden="true" /> Next</button>
</ButtonStyled>
<ButtonStyled v-else-if="!generatedMessage" color="brand">
<button :disabled="loadingMessage" @click="generateMessage">
<UpdatedIcon aria-hidden="true" /> Generate message
</button>
</ButtonStyled>
<template v-if="generatedMessage && !done">
<ButtonStyled color="green">
<button @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" /> Approve
</button>
</ButtonStyled>
<div class="joined-buttons">
<ButtonStyled color="red">
<button @click="sendMessage('rejected')">
<CrossIcon aria-hidden="true" /> Reject
</button>
</ButtonStyled>
<ButtonStyled color="red">
<OverflowMenu
class="btn-dropdown-animation"
:options="[
{
id: 'withhold',
color: 'danger',
action: () => sendMessage('withheld'),
hoverFilled: true,
},
]"
>
<DropdownIcon style="rotate: 180deg" />
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
Next project
</button> </button>
</div> </div>
</div> </div>
<div class="input-group modpack-buttons">
<button class="btn" :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
<button
class="btn btn-blue"
:disabled="!modPackData[modPackIndex].status"
@click="modPackIndex += 1"
>
<RightArrowIcon aria-hidden="true" />
Next project
</button>
</div>
</div> </div>
</div> </Collapsible>
<div v-else>
<h2>{{ steps[currentStepIndex].question }}</h2>
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
<strong>Rules guidance:</strong>
<ul>
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
{{ rule }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
>
<strong>Examples of what to reject:</strong>
<ul>
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
{{ example }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
>
<strong>Exceptions:</strong>
<ul>
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
{{ exception }}
</li>
</ul>
</template>
<p v-if="steps[currentStepIndex].id === 'title'">
<strong>Title:</strong> {{ project.title }}
</p>
<p v-if="steps[currentStepIndex].id === 'slug'"><strong>Slug:</strong> {{ project.slug }}</p>
<p v-if="steps[currentStepIndex].id === 'summary'">
<strong>Summary:</strong> {{ project.description }}
</p>
<p v-if="steps[currentStepIndex].id === 'links'">
<template v-if="project.issues_url">
<strong>Issues: </strong>
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
</template>
<template v-if="project.source_url">
<strong>Source: </strong>
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
</template>
<template v-if="project.wiki_url">
<strong>Wiki: </strong>
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
</template>
<template v-if="project.discord_url">
<strong>Discord: </strong>
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
<br />
</template>
<template v-for="(donation, index) in project.donation_urls" :key="index">
<strong>{{ donation.platform }}: </strong>
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
<br />
</template>
</p>
<p v-if="steps[currentStepIndex].id === 'categories'">
<strong>Categories:</strong>
<Categories
:categories="project.categories.concat(project.additional_categories)"
:type="project.actualProjectType"
class="categories"
/>
</p>
<p v-if="steps[currentStepIndex].id === 'side-types'">
<strong>Client side:</strong> {{ project.client_side }} <br />
<strong>Server side:</strong> {{ project.server_side }}
</p>
<div class="options input-group">
<button
v-for="(option, index) in steps[currentStepIndex].options"
:key="index"
class="btn"
:class="{
'option-selected':
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
}"
@click="toggleOption(steps[currentStepIndex].id, option)"
>
{{ option.name }}
</button>
</div>
<div
v-if="
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].length > 0
"
class="inputs universal-labels"
>
<div
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
(x) => x.fillers && x.fillers.length > 0,
)"
:key="index"
>
<div v-for="(filler, idx) in option.fillers" :key="idx">
<label :for="filler.id">
<span class="label__title">
{{ filler.question }}
<span v-if="filler.required" class="required">*</span>
</span>
</label>
<div v-if="filler.large" class="markdown-editor-spacing">
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
</div>
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
</div>
</div>
</div>
</div>
<div class="input-group modpack-buttons">
<button v-if="!done" class="btn skip-btn" aria-label="Skip" @click="goToNextProject">
<ExitIcon aria-hidden="true" />
<template v-if="futureProjects.length > 0">Skip</template>
<template v-else>Exit</template>
</button>
<button v-if="currentStepIndex > 0" class="btn" @click="previousPage() && !done">
<LeftArrowIcon aria-hidden="true" /> Previous
</button>
<button
v-if="currentStepIndex < steps.length - 1 && !done"
class="btn btn-primary"
@click="nextPage()"
>
<RightArrowIcon aria-hidden="true" /> Next
</button>
<button
v-else-if="!generatedMessage"
class="btn btn-primary"
:disabled="loadingMessage"
@click="generateMessage"
>
<UpdatedIcon aria-hidden="true" /> Generate message
</button>
<template v-if="generatedMessage && !done">
<button class="btn btn-green" @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" /> Approve
</button>
<div class="joined-buttons">
<button class="btn btn-danger" @click="sendMessage('rejected')">
<CrossIcon aria-hidden="true" /> Reject
</button>
<OverflowMenu
class="btn btn-danger btn-dropdown-animation icon-only"
:options="[
{
id: 'withhold',
color: 'danger',
action: () => sendMessage('withheld'),
hoverFilled: true,
},
]"
>
<DropdownIcon style="rotate: 180deg" />
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
</OverflowMenu>
</div>
</template>
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
Next project
</button>
</div>
</div> </div>
</template> </template>
@@ -337,8 +374,9 @@ import {
XIcon as CrossIcon, XIcon as CrossIcon,
EyeOffIcon, EyeOffIcon,
ExitIcon, ExitIcon,
ScaleIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { MarkdownEditor, OverflowMenu } from "@modrinth/ui"; import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
import Categories from "~/components/ui/search/Categories.vue"; import Categories from "~/components/ui/search/Categories.vue";
const props = defineProps({ const props = defineProps({
@@ -355,8 +393,14 @@ const props = defineProps({
required: true, required: true,
default: () => {}, default: () => {},
}, },
collapsed: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(["exit", "toggleCollapsed"]);
const steps = computed(() => const steps = computed(() =>
[ [
{ {
@@ -1008,6 +1052,20 @@ async function sendMessage(status) {
const router = useNativeRouter(); const router = useNativeRouter();
async function exitModeration() {
await router.push({
name: "type-id",
params: {
type: "project",
id: props.project.id,
},
state: {
showChecklist: false,
},
});
emit("exit");
}
async function goToNextProject() { async function goToNextProject() {
const project = props.futureProjects[0]; const project = props.futureProjects[0];
@@ -1031,23 +1089,8 @@ async function goToNextProject() {
<style scoped lang="scss"> <style scoped lang="scss">
.moderation-checklist { .moderation-checklist {
position: sticky; @media (prefers-reduced-motion) {
bottom: 0; transition: none !important;
left: 100vw;
z-index: 100;
border: 1px solid var(--color-bg-inverted);
width: 600px;
.skip-btn {
margin-right: auto;
}
.next-project {
margin-left: auto;
}
.modpack-buttons {
margin-top: 1rem;
} }
.option-selected { .option-selected {

View File

@@ -76,7 +76,12 @@ function pickLink() {
subpageSelected.value = false; subpageSelected.value = false;
for (let i = filteredLinks.value.length - 1; i >= 0; i--) { for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]; const link = filteredLinks.value[i];
if (decodeURIComponent(route.path) === link.href) { if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
index = i;
break;
}
} else if (decodeURIComponent(route.path) === link.href) {
index = i; index = i;
break; break;
} else if ( } else if (
@@ -150,7 +155,7 @@ onMounted(() => {
}); });
watch( watch(
() => route.path, () => [route.path, route.query],
() => pickLink(), () => pickLink(),
); );
</script> </script>

View File

@@ -19,13 +19,21 @@
</nuxt-link> </nuxt-link>
</div> </div>
<div v-else-if="report.item_type === 'user'" class="item-info"> <div v-else-if="report.item_type === 'user'" class="item-info">
<nuxt-link :to="`/user/${report.user.username}`" class="iconified-stacked-link"> <nuxt-link
v-if="report.user"
:to="`/user/${report.user.username}`"
class="iconified-stacked-link"
>
<Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" /> <Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" />
<div class="stacked"> <div class="stacked">
<span class="title">{{ report.user.username }}</span> <span class="title">{{ report.user.username }}</span>
<span>User</span> <span>User</span>
</div> </div>
</nuxt-link> </nuxt-link>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
<span>Reported user not found: <CopyCode :text="report.item_id" /> </span>
</div>
</div> </div>
<div v-else-if="report.item_type === 'version'" class="item-info"> <div v-else-if="report.item_type === 'version'" class="item-info">
<nuxt-link <nuxt-link
@@ -50,7 +58,7 @@
</div> </div>
<div v-else class="item-info"> <div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div> <div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
<span>Unknown report type</span> <span>Unknown report type: {{ report.item_type }}</span>
</div> </div>
<div class="report-type"> <div class="report-type">
<Badge v-if="report.closed" type="closed" /> <Badge v-if="report.closed" type="closed" />

View File

@@ -1,5 +1,4 @@
<template> <template>
<Chips v-if="false" v-model="viewMode" :items="['open', 'archived']" />
<ReportInfo <ReportInfo
v-for="report in reports.filter( v-for="report in reports.filter(
(x) => (x) =>
@@ -17,7 +16,6 @@
<p v-if="reports.length === 0">You don't have any active reports.</p> <p v-if="reports.length === 0">You don't have any active reports.</p>
</template> </template>
<script setup> <script setup>
import Chips from "~/components/ui/Chips.vue";
import ReportInfo from "~/components/ui/report/ReportInfo.vue"; import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js"; import { addReportMessage } from "~/helpers/threads.js";

View File

@@ -106,6 +106,7 @@ const fetchSettings = async () => {
initialSettings.value = settings as { interval: number; enabled: boolean }; initialSettings.value = settings as { interval: number; enabled: boolean };
autoBackupEnabled.value = settings?.enabled ?? false; autoBackupEnabled.value = settings?.enabled ?? false;
autoBackupInterval.value = settings?.interval || 6; autoBackupInterval.value = settings?.interval || 6;
return true;
} catch (error) { } catch (error) {
console.error("Error fetching backup settings:", error); console.error("Error fetching backup settings:", error);
addNotification({ addNotification({
@@ -114,6 +115,7 @@ const fetchSettings = async () => {
text: "Failed to load backup settings", text: "Failed to load backup settings",
type: "error", type: "error",
}); });
return false;
} finally { } finally {
isLoadingSettings.value = false; isLoadingSettings.value = false;
} }
@@ -155,8 +157,10 @@ const saveSettings = async () => {
defineExpose({ defineExpose({
show: async () => { show: async () => {
await fetchSettings(); const success = await fetchSettings();
modal.value?.show(); if (success) {
modal.value?.show();
}
}, },
}); });
</script> </script>

View File

@@ -0,0 +1,530 @@
<template>
<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`" />
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
</div>
</template>
<div class="flex flex-col gap-2 md:w-[420px]">
<div class="flex flex-col gap-2">
<template v-if="versionsLoading">
<div class="flex items-center gap-2">
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg font-semibold">
<span class="opacity-0" aria-hidden="true">{{ type }} version</span>
</div>
<div class="min-h-[22px] min-w-[140px] animate-pulse rounded-full bg-button-bg" />
</div>
<div class="min-h-9 w-full animate-pulse rounded-xl bg-button-bg" />
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg">
<span class="ml-6 opacity-0" aria-hidden="true">
Show any beta and alpha releases
</span>
</div>
</template>
<template v-else>
<div class="flex justify-between">
<div class="flex items-center gap-2">
<div class="font-semibold text-contrast">{{ type }} version</div>
<NuxtLink
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
@click="
versionFilter &&
(unlockFilterAccordion.isOpen
? unlockFilterAccordion.close()
: unlockFilterAccordion.open())
"
>
<TagItem
v-if="formattedVersions.game_versions.length > 0"
v-tooltip="formattedVersions.game_versions.join(', ')"
:style="`--_color: var(--color-green)`"
>
{{ formattedVersions.game_versions[0] }}
</TagItem>
<TagItem
v-if="formattedVersions.loaders.length > 0"
v-tooltip="formattedVersions.loaders.join(', ')"
:style="`--_color: var(--color-platform-${formattedVersions.loaders[0].toLowerCase()})`"
>
{{ formattedVersions.loaders[0] }}
</TagItem>
<DropdownIcon
:class="[
'transition-all duration-200 ease-in-out',
{ 'rotate-180': unlockFilterAccordion.isOpen },
{ 'opacity-0': !versionFilter },
]"
/>
</NuxtLink>
</div>
</div>
<UiServersTeleportDropdownMenu
v-model="selectedVersion"
name="Project"
:options="filteredVersions"
placeholder="No valid versions found"
class="!min-w-full"
:disabled="filteredVersions.length === 0"
:display-name="
(version) => (typeof version === 'object' ? version?.version_number : version)
"
/>
<Checkbox v-model="showBetaAlphaReleases"> Show any beta and alpha releases </Checkbox>
</template>
</div>
<Accordion
ref="unlockFilterAccordion"
:open-by-default="!versionFilter"
:class="[
versionFilter ? '' : '!border-solid border-orange bg-bg-orange !text-contrast',
'flex flex-col gap-2 rounded-2xl border-2 border-dashed border-divider p-3 transition-all',
]"
>
<p class="m-0 items-center font-bold">
<span>
{{
noCompatibleVersions
? `No compatible versions of this ${type.toLowerCase()} were found`
: versionFilter
? "Game version and platform is provided by the server"
: "Incompatible game version and platform versions are unlocked"
}}
</span>
</p>
<p class="m-0 text-sm">
{{
noCompatibleVersions
? `No versions compatible with your server were found. You can still select any available version.`
: versionFilter
? `Unlocking this filter may allow you to change this ${type.toLowerCase()}
to an incompatible version.`
: "You might see versions listed that aren't compatible with your server configuration."
}}
</p>
<ContentVersionFilter
v-if="currentVersions"
ref="filtersRef"
:versions="currentVersions"
:game-versions="tags.gameVersions"
:select-classes="'w-full'"
:type="type"
:disabled="versionFilter"
:platform-tags="tags.loaders"
:listed-game-versions="gameVersions"
:listed-platforms="platforms"
@update:query="updateFiltersFromUi($event)"
@vue:mounted="updateFiltersToUi"
>
<template #platform>
<LoaderIcon
v-if="filtersRef?.selectedPlatforms.length === 0"
:loader="'Vanilla'"
class="size-5 flex-none"
/>
<svg
v-else
class="size-5 flex-none"
v-html="tags.loaders.find((x) => x.name === filtersRef?.selectedPlatforms[0])?.icon"
></svg>
<div class="w-full truncate text-left">
{{
filtersRef?.selectedPlatforms.length === 0
? "All platforms"
: filtersRef?.selectedPlatforms.map((x) => formatCategory(x)).join(", ")
}}
</div>
</template>
<template #game-versions>
<GameIcon class="size-5 flex-none" />
<div class="w-full truncate text-left">
{{
filtersRef?.selectedGameVersions.length === 0
? "All game versions"
: filtersRef?.selectedGameVersions.join(", ")
}}
</div>
</template>
</ContentVersionFilter>
<ButtonStyled v-if="!noCompatibleVersions" color-fill="text">
<button
class="w-full"
:disabled="gameVersions.length < 2 && platforms.length < 2"
@click="
versionFilter = !versionFilter;
setInitialFilters();
updateFiltersToUi();
"
>
<LockOpenIcon />
{{
gameVersions.length < 2 && platforms.length < 2
? "No other platforms or versions available"
: versionFilter
? "Unlock"
: "Return to compatibility"
}}
</button>
</ButtonStyled>
</Accordion>
<Admonition
v-if="versionsError"
type="critical"
header="Failed to load versions"
class="mb-2"
>
<div>
<span>
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" />
</div>
</Admonition>
<Admonition
v-else-if="props.modPack"
type="warning"
header="Changing version may cause issues"
class="mb-2"
>
Your server was created using a modpack. It's recommended to use the modpack's version of
the mod.
<NuxtLink
class="mt-2 flex items-center gap-1"
:to="`/servers/manage/${props.serverId}/options/loader`"
target="_blank"
>
<ExternalIcon class="size-5 flex-none"></ExternalIcon> Modify modpack version
</NuxtLink>
</Admonition>
<div class="flex flex-row items-center gap-4">
<ButtonStyled color="brand">
<button
:disabled="versionsLoading || selectedVersion.id === modDetails?.version_id"
@click="emitChangeModVersion"
>
<CheckIcon />
Install
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modModal.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import {
DropdownIcon,
XIcon,
CheckIcon,
LockOpenIcon,
GameIcon,
ExternalIcon,
} from "@modrinth/assets";
import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
import { ref, computed } from "vue";
import { formatCategory, formatVersionsForDisplay, type Version } from "@modrinth/utils";
import Accordion from "~/components/ui/Accordion.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import ContentVersionFilter, {
type ListedGameVersion,
type ListedPlatform,
} from "~/components/ui/servers/ContentVersionFilter.vue";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
const props = defineProps<{
type: "Mod" | "Plugin";
loader: string;
gameVersion: string;
modPack: boolean;
serverId: string;
}>();
interface ContentItem extends Mod {
changing?: boolean;
}
interface EditVersion extends Version {
installed: boolean;
upgrade?: boolean;
}
const modModal = ref();
const modDetails = ref<ContentItem>();
const currentVersions = ref<EditVersion[] | null>(null);
const versionsLoading = ref(false);
const versionsError = ref("");
const showBetaAlphaReleases = ref(false);
const unlockFilterAccordion = ref();
const versionFilter = ref(true);
const tags = useTags();
const noCompatibleVersions = ref(false);
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(
(acc, tag) => {
if (tag.supported_project_types.includes("plugin")) {
acc.pluginLoaders.push(tag.name);
}
if (tag.supported_project_types.includes("mod")) {
acc.modLoaders.push(tag.name);
}
return acc;
},
{ pluginLoaders: [] as string[], modLoaders: [] as string[] },
);
const selectedVersion = ref();
const filtersRef: Ref<InstanceType<typeof ContentVersionFilter> | null> = ref(null);
interface SelectedContentFilters {
selectedGameVersions: string[];
selectedPlatforms: string[];
}
const selectedFilters = ref<SelectedContentFilters>({
selectedGameVersions: [],
selectedPlatforms: [],
});
const backwardCompatPlatformMap = {
purpur: ["purpur", "paper", "spigot", "bukkit"],
paper: ["paper", "spigot", "bukkit"],
spigot: ["spigot", "bukkit"],
};
const platforms = ref<ListedPlatform[]>([]);
const gameVersions = ref<ListedGameVersion[]>([]);
const initPlatform = ref<string>("");
const setInitialFilters = () => {
selectedFilters.value = {
selectedGameVersions: [
gameVersions.value.find((version) => version.name === props.gameVersion)?.name ??
gameVersions.value.find((version) => version.release)?.name ??
gameVersions.value[0]?.name,
],
selectedPlatforms: [initPlatform.value],
};
};
const updateFiltersToUi = () => {
if (!filtersRef.value) return;
filtersRef.value.selectedGameVersions = selectedFilters.value.selectedGameVersions;
filtersRef.value.selectedPlatforms = selectedFilters.value.selectedPlatforms;
selectedVersion.value = filteredVersions.value[0];
};
const updateFiltersFromUi = (event: { g: string[]; l: string[] }) => {
selectedFilters.value = {
selectedGameVersions: event.g,
selectedPlatforms: event.l,
};
};
const filteredVersions = computed(() => {
if (!currentVersions.value) return [];
const versionsWithoutReleaseFilter = currentVersions.value.filter((version: EditVersion) => {
if (version.installed) return true;
return (
filtersRef.value?.selectedPlatforms.every((platform) =>
(
backwardCompatPlatformMap[platform as keyof typeof backwardCompatPlatformMap] || [
platform,
]
).some((loader) => version.loaders.includes(loader)),
) &&
filtersRef.value?.selectedGameVersions.every((gameVersion) =>
version.game_versions.includes(gameVersion),
)
);
});
const versionTypes = new Set(
versionsWithoutReleaseFilter.map((v: EditVersion) => v.version_type),
);
const releaseVersions = versionTypes.has("release");
const betaVersions = versionTypes.has("beta");
const alphaVersions = versionTypes.has("alpha");
const versions = versionsWithoutReleaseFilter.filter((version: EditVersion) => {
if (showBetaAlphaReleases.value || version.installed) return true;
return releaseVersions
? version.version_type === "release"
: betaVersions
? version.version_type === "beta"
: alphaVersions
? version.version_type === "alpha"
: false;
});
return versions.map((version: EditVersion) => {
let suffix = "";
if (version.version_type === "alpha" && releaseVersions && betaVersions) {
suffix += " (alpha)";
} else if (version.version_type === "beta" && releaseVersions) {
suffix += " (beta)";
}
return {
...version,
version_number: version.version_number + suffix,
};
});
});
const formattedVersions = computed(() => {
return {
game_versions: formatVersionsForDisplay(
selectedVersion.value?.game_versions || [],
tags.value.gameVersions,
),
loaders: (selectedVersion.value?.loaders || [])
.sort((firstLoader: string, secondLoader: string) => {
const loaderList = backwardCompatPlatformMap[
props.loader as keyof typeof backwardCompatPlatformMap
] || [props.loader];
const firstLoaderPosition = loaderList.indexOf(firstLoader.toLowerCase());
const secondLoaderPosition = loaderList.indexOf(secondLoader.toLowerCase());
if (firstLoaderPosition === -1 && secondLoaderPosition === -1) return 0;
if (firstLoaderPosition === -1) return 1;
if (secondLoaderPosition === -1) return -1;
return firstLoaderPosition - secondLoaderPosition;
})
.map((loader: string) => formatCategory(loader)),
};
});
async function show(mod: ContentItem) {
versionFilter.value = true;
modModal.value.show();
versionsLoading.value = true;
modDetails.value = mod;
versionsError.value = "";
currentVersions.value = null;
try {
const result = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
if (
Array.isArray(result) &&
result.every(
(item) =>
"id" in item &&
"version_number" in item &&
"version_type" in item &&
"loaders" in item &&
"game_versions" in item,
)
) {
currentVersions.value = result as EditVersion[];
} else {
throw new Error("Invalid version data received.");
}
// find the installed version and move it to the top of the list
const currentModIndex = currentVersions.value.findIndex(
(item: { id: string }) => item.id === mod.version_id,
);
if (currentModIndex === -1) {
currentVersions.value[currentModIndex] = {
...currentVersions.value[currentModIndex],
installed: true,
version_number: `${mod.version_number} (current) (external)`,
};
} else {
currentVersions.value[currentModIndex].version_number = `${mod.version_number} (current)`;
currentVersions.value[currentModIndex].installed = true;
}
// initially filter the platform and game versions for the server config
const platformSet = new Set<string>();
const gameVersionSet = new Set<string>();
for (const version of currentVersions.value) {
for (const loader of version.loaders) {
platformSet.add(loader);
}
for (const gameVersion of version.game_versions) {
gameVersionSet.add(gameVersion);
}
}
if (gameVersionSet.size > 0) {
const filteredGameVersions = tags.value.gameVersions.filter((x) =>
gameVersionSet.has(x.version),
);
gameVersions.value = filteredGameVersions.map((x) => ({
name: x.version,
release: x.version_type === "release",
}));
}
if (platformSet.size > 0) {
const tempPlatforms = Array.from(platformSet).map((platform) => ({
name: platform,
isType:
props.type === "Plugin"
? pluginLoaders.includes(platform)
: props.type === "Mod"
? modLoaders.includes(platform)
: false,
}));
platforms.value = tempPlatforms;
}
// set default platform
const defaultPlatform = Array.from(platformSet)[0];
initPlatform.value = platformSet.has(props.loader)
? props.loader
: props.loader in backwardCompatPlatformMap
? backwardCompatPlatformMap[props.loader as keyof typeof backwardCompatPlatformMap].find(
(p) => platformSet.has(p),
) || defaultPlatform
: defaultPlatform;
// check if there's nothing compatible with the server config
noCompatibleVersions.value =
!platforms.value.some((p) => p.isType) ||
!gameVersions.value.some((v) => v.name === props.gameVersion);
if (noCompatibleVersions.value) {
unlockFilterAccordion.value.open();
versionFilter.value = false;
}
setInitialFilters();
versionsLoading.value = false;
} catch (error) {
console.error("Error loading versions:", error);
versionsError.value = error instanceof Error ? error.message : "Unknown";
}
}
const emit = defineEmits<{
changeVersion: [string];
}>();
function emitChangeModVersion() {
if (!selectedVersion.value) return;
emit("changeVersion", selectedVersion.value.id.toString());
}
defineExpose({
show,
hide: () => modModal.value.hide(),
});
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="experimental-styles-within flex w-full flex-col items-center gap-2">
<ManySelect
v-model="selectedPlatforms"
:tooltip="
filterOptions.platform.length < 2 && !disabled ? 'No other platforms available' : undefined
"
:options="filterOptions.platform"
:dropdown-id="`${baseId}-platform`"
search
show-always
class="w-full"
:disabled="disabled || filterOptions.platform.length < 2"
:dropdown-class="'w-full'"
@change="updateFilters"
>
<slot name="platform">
<FilterIcon class="h-5 w-5 text-secondary" />
Platform
</slot>
<template #option="{ option }">
{{ formatCategory(option) }}
</template>
<template v-if="hasAnyUnsupportedPlatforms" #footer>
<Checkbox
v-model="showSupportedPlatformsOnly"
class="mx-1"
:label="`Show ${type?.toLowerCase()} platforms only`"
/>
</template>
</ManySelect>
<ManySelect
v-model="selectedGameVersions"
:tooltip="
filterOptions.gameVersion.length < 2 && !disabled
? 'No other game versions available'
: undefined
"
:options="filterOptions.gameVersion"
:dropdown-id="`${baseId}-game-version`"
search
show-always
class="w-full"
:disabled="disabled || filterOptions.gameVersion.length < 2"
:dropdown-class="'w-full'"
@change="updateFilters"
>
<slot name="game-versions">
<FilterIcon class="h-5 w-5 text-secondary" />
Game versions
</slot>
<template v-if="hasAnySnapshots" #footer>
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
</template>
</ManySelect>
</div>
</template>
<script setup lang="ts">
import { FilterIcon } from "@modrinth/assets";
import { type Version, formatCategory, type GameVersionTag } from "@modrinth/utils";
import { ref, computed } from "vue";
import { useRoute } from "vue-router";
import ManySelect from "@modrinth/ui/src/components/base/ManySelect.vue";
import Checkbox from "@modrinth/ui/src/components/base/Checkbox.vue";
export type ListedGameVersion = {
name: string;
release: boolean;
};
export type ListedPlatform = {
name: string;
isType: boolean;
};
const props = defineProps<{
versions: Version[];
gameVersions: GameVersionTag[];
listedGameVersions: ListedGameVersion[];
listedPlatforms: ListedPlatform[];
baseId?: string;
type: "Mod" | "Plugin";
platformTags: {
name: string;
supported_project_types: string[];
}[];
disabled?: boolean;
}>();
const emit = defineEmits(["update:query"]);
const route = useRoute();
const showSnapshots = ref(false);
const hasAnySnapshots = computed(() => {
return props.versions.some((x) =>
props.gameVersions.some(
(y) => y.version_type !== "release" && x.game_versions.includes(y.version),
),
);
});
const hasOnlySnapshots = computed(() => {
return props.versions.every((version) => {
return version.game_versions.every((gv) => {
const matched = props.gameVersions.find((tag) => tag.version === gv);
return matched && matched.version_type !== "release";
});
});
});
const hasAnyUnsupportedPlatforms = computed(() => {
return props.listedPlatforms.some((x) => !x.isType);
});
const hasOnlyUnsupportedPlatforms = computed(() => {
return props.listedPlatforms.every((x) => !x.isType);
});
const showSupportedPlatformsOnly = ref(true);
const filterOptions = computed(() => {
const filters: Record<"gameVersion" | "platform", string[]> = {
gameVersion: [],
platform: [],
};
filters.gameVersion = props.listedGameVersions
.filter((x) => {
return showSnapshots.value || hasOnlySnapshots.value ? true : x.release;
})
.map((x) => x.name);
filters.platform = props.listedPlatforms
.filter((x) => {
return !showSupportedPlatformsOnly.value || hasOnlyUnsupportedPlatforms.value
? true
: x.isType;
})
.map((x) => x.name);
return filters;
});
const selectedGameVersions = ref<string[]>([]);
const selectedPlatforms = ref<string[]>([]);
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
function updateFilters() {
emit("update:query", {
g: selectedGameVersions.value,
l: selectedPlatforms.value,
});
}
defineExpose({
selectedGameVersions,
selectedPlatforms,
});
function getArrayOrString(x: string | (string | null)[]): string[] {
if (typeof x === "string") {
return [x];
} else {
return x.filter((item): item is string => item !== null);
}
}
</script>
<style></style>

View File

@@ -75,7 +75,7 @@ import {
RightArrowIcon, RightArrowIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { computed, shallowRef, ref } from "vue"; import { computed, shallowRef, ref } from "vue";
import { renderToString } from "@vue/server-renderer"; import { renderToString } from "vue/server-renderer";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { import {
UiServersIconsCogFolderIcon, UiServersIconsCogFolderIcon,

View File

@@ -2,7 +2,7 @@
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel /> <div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
<header <header
:class="[ :class="[
'duration-20 h-26 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row', 'duration-20 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20', !isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
]" ]"
data-pyro-files-state="browsing" data-pyro-files-state="browsing"
@@ -76,25 +76,23 @@
<UiServersTeleportOverflowMenu <UiServersTeleportOverflowMenu
position="bottom" position="bottom"
direction="left" direction="left"
aria-label="Sort files" aria-label="Filter view"
:options="[ :options="[
{ id: 'normal', action: () => $emit('sort', 'default') }, { id: 'all', action: () => $emit('filter', 'all') },
{ id: 'modified', action: () => $emit('sort', 'modified') }, { id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
{ id: 'created', action: () => $emit('sort', 'created') }, { id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
]" ]"
> >
<span class="hidden whitespace-pre text-sm font-medium sm:block"> <div class="flex items-center gap-1">
{{ sortMethodLabel }} <FilterIcon aria-hidden="true" class="h-5 w-5" />
</span> <span class="hidden text-sm font-medium sm:block">
<SortAscendingIcon aria-hidden="true" /> {{ filterLabel }}
</span>
</div>
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" /> <DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #normal> Alphabetical </template> <template #all>Show all</template>
<template #modified> Date modified </template> <template #filesOnly>Files only</template>
<template #created> Date created </template> <template #foldersOnly>Folders only</template>
<template #filesOnly> Files only </template>
<template #foldersOnly> Folders only </template>
</UiServersTeleportOverflowMenu> </UiServersTeleportOverflowMenu>
</ButtonStyled> </ButtonStyled>
<div class="mx-1 w-full text-sm sm:w-48"> <div class="mx-1 w-full text-sm sm:w-48">
@@ -148,9 +146,9 @@ import {
DropdownIcon, DropdownIcon,
FolderOpenIcon, FolderOpenIcon,
SearchIcon, SearchIcon,
SortAscendingIcon,
HomeIcon, HomeIcon,
ChevronRightIcon, ChevronRightIcon,
FilterIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
@@ -159,15 +157,15 @@ import { useIntersectionObserver } from "@vueuse/core";
const props = defineProps<{ const props = defineProps<{
breadcrumbSegments: string[]; breadcrumbSegments: string[];
searchQuery: string; searchQuery: string;
sortMethod: string; currentFilter: string;
}>(); }>();
defineEmits<{ defineEmits<{
(e: "navigate", index: number): void; (e: "navigate", index: number): void;
(e: "sort", method: string): void;
(e: "create", type: "file" | "directory"): void; (e: "create", type: "file" | "directory"): void;
(e: "upload"): void; (e: "upload"): void;
(e: "update:searchQuery", value: string): void; (e: "update:searchQuery", value: string): void;
(e: "filter", type: string): void;
}>(); }>();
const pyroFilesSentinel = ref<HTMLElement | null>(null); const pyroFilesSentinel = ref<HTMLElement | null>(null);
@@ -181,18 +179,14 @@ useIntersectionObserver(
{ threshold: [0, 1] }, { threshold: [0, 1] },
); );
const sortMethodLabel = computed(() => { const filterLabel = computed(() => {
switch (props.sortMethod) { switch (props.currentFilter) {
case "modified":
return "Date modified";
case "created":
return "Date created";
case "filesOnly": case "filesOnly":
return "Files only"; return "Files only";
case "foldersOnly": case "foldersOnly":
return "Folders only"; return "Folders only";
default: default:
return "Alphabetical"; return "Show all";
} }
}); });
</script> </script>

View File

@@ -9,7 +9,7 @@
@mouseleave="stopPan" @mouseleave="stopPan"
@wheel.prevent="handleWheel" @wheel.prevent="handleWheel"
> >
<UiServersPyroLoading v-if="state.isLoading" /> <div v-if="state.isLoading" />
<div <div
v-if="state.hasError" v-if="state.hasError"
class="flex h-full w-full flex-col items-center justify-center gap-8" class="flex h-full w-full flex-col items-center justify-center gap-8"

View File

@@ -1,14 +1,65 @@
<template> <template>
<div <div
aria-hidden="true" aria-hidden="true"
class="flex w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised px-3 py-2 text-xs font-bold uppercase" class="sticky top-12 z-20 flex h-8 w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised bg-bg px-3 text-xs font-bold uppercase"
> >
<div class="min-w-[48px]"></div> <div class="min-w-[48px]"></div>
<span class="flex w-full">Name</span> <button
class="flex h-full w-full appearance-none items-center gap-1 bg-transparent text-left hover:text-brand"
@click="$emit('sort', 'name')"
>
<span>Name</span>
<ChevronUpIcon v-if="sortField === 'name' && !sortDesc" class="h-3 w-3" aria-hidden="true" />
<ChevronDownIcon v-if="sortField === 'name' && sortDesc" class="h-3 w-3" aria-hidden="true" />
</button>
<div class="flex shrink-0 gap-4 text-right md:gap-12"> <div class="flex shrink-0 gap-4 text-right md:gap-12">
<span class="hidden min-w-[160px] md:block">Created</span> <button
<span class="mr-4 min-w-[160px]">Modified</span> class="hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
<div class="min-w-[36px]"></div> @click="$emit('sort', 'created')"
>
<span>Created</span>
<ChevronUpIcon
v-if="sortField === 'created' && !sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'created' && sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
</button>
<button
class="mr-4 hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
@click="$emit('sort', 'modified')"
>
<span>Modified</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
</button>
<div class="min-w-[24px]"></div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
import ChevronDownIcon from "./icons/ChevronDownIcon.vue";
import ChevronUpIcon from "./icons/ChevronUpIcon.vue";
defineProps<{
sortField: string;
sortDesc: boolean;
}>();
defineEmits<{
(e: "sort", field: string): void;
}>();
</script>

View File

@@ -0,0 +1,309 @@
<template>
<div ref="container" class="relative h-[400px] w-full cursor-move lg:h-[600px]">
<div
v-for="location in locations"
:key="location.name"
:class="{
'opacity-0': !showLabels,
hidden: !isLocationVisible(location),
'z-40': location.clicked,
}"
:style="{
position: 'absolute',
left: `${location.screenPosition?.x || 0}px`,
top: `${location.screenPosition?.y || 0}px`,
}"
class="location-button center-on-top-left flex transform cursor-pointer items-center rounded-full bg-bg px-3 outline-1 outline-red transition-opacity duration-200 hover:z-50"
@click="toggleLocationClicked(location)"
>
<div
:class="{
'animate-pulse': location.active,
'border-gray-400': !location.active,
'border-purple bg-purple': location.active,
'border-dashed': !location.active,
'opacity-40': !location.active,
}"
class="my-3 size-2.5 shrink-0 rounded-full border-2"
></div>
<div
class="expanding-item"
:class="{
expanded: location.clicked,
}"
>
<div class="whitespace-nowrap text-sm">
<span class="ml-2"> {{ location.name }} </span>
<span v-if="!location.active" class="ml-1 text-xs text-secondary">(Coming Soon)</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { ref, onMounted, onUnmounted } from "vue";
const container = ref(null);
const showLabels = ref(false);
const locations = ref([
// Active locations
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
{ name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false },
{ name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, clicked: false },
// Future Locations
// { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false },
// { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false },
// { name: "Amsterdam", lat: 52.3676, lng: 4.9041, active: false, clicked: false },
// { name: "Paris", lat: 48.8566, lng: 2.3522, active: false, clicked: false },
// { name: "Singapore", lat: 1.3521, lng: 103.8198, active: false, clicked: false },
// { name: "Tokyo", lat: 35.6762, lng: 139.6503, active: false, clicked: false },
// { name: "Sydney", lat: -33.8688, lng: 151.2093, active: false, clicked: false },
// { name: "São Paulo", lat: -23.5505, lng: -46.6333, active: false, clicked: false },
// { name: "Toronto", lat: 43.6532, lng: -79.3832, active: false, clicked: false },
]);
const isLocationVisible = (location) => {
if (!location.screenPosition || !globe) return false;
const vector = latLngToVector3(location.lat, location.lng).clone();
vector.applyMatrix4(globe.matrixWorld);
const cameraVector = new THREE.Vector3();
camera.getWorldPosition(cameraVector);
const viewVector = vector.clone().sub(cameraVector).normalize();
const normal = vector.clone().normalize();
const dotProduct = normal.dot(viewVector);
return dotProduct < -0.15;
};
const toggleLocationClicked = (location) => {
console.log("clicked", location.name);
locations.value.find((loc) => loc.name === location.name).clicked = !location.clicked;
};
let scene, camera, renderer, globe, controls;
let animationFrame;
const init = () => {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
45,
container.value.clientWidth / container.value.clientHeight,
0.1,
1000,
);
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "low-power",
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
container.value.appendChild(renderer.domElement);
const geometry = new THREE.SphereGeometry(5, 64, 64);
const outlineTexture = new THREE.TextureLoader().load("/earth-outline.png");
outlineTexture.minFilter = THREE.LinearFilter;
outlineTexture.magFilter = THREE.LinearFilter;
const material = new THREE.ShaderMaterial({
uniforms: {
outlineTexture: { value: outlineTexture },
globeColor: { value: new THREE.Color("#60fbb5") },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D outlineTexture;
uniform vec3 globeColor;
varying vec2 vUv;
void main() {
vec4 texColor = texture2D(outlineTexture, vUv);
float brightness = max(max(texColor.r, texColor.g), texColor.b);
gl_FragColor = vec4(globeColor, brightness * 0.8);
}
`,
transparent: true,
side: THREE.FrontSide,
});
globe = new THREE.Mesh(geometry, material);
scene.add(globe);
const atmosphereGeometry = new THREE.SphereGeometry(5.2, 64, 64);
const atmosphereMaterial = new THREE.ShaderMaterial({
transparent: true,
side: THREE.BackSide,
uniforms: {
color: { value: new THREE.Color("#56f690") },
viewVector: { value: camera.position },
},
vertexShader: `
uniform vec3 viewVector;
varying float intensity;
void main() {
vec3 vNormal = normalize(normalMatrix * normal);
vec3 vNormel = normalize(normalMatrix * viewVector);
intensity = pow(0.7 - dot(vNormal, vNormel), 2.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 color;
varying float intensity;
void main() {
gl_FragColor = vec4(color, intensity * 0.4);
}
`,
});
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial);
scene.add(atmosphere);
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
scene.add(ambientLight);
camera.position.z = 15;
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.rotateSpeed = 0.3;
controls.enableZoom = false;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.05;
controls.minPolarAngle = Math.PI * 0.3;
controls.maxPolarAngle = Math.PI * 0.7;
globe.rotation.y = Math.PI * 1.9;
globe.rotation.x = Math.PI * 0.15;
};
const animate = () => {
animationFrame = requestAnimationFrame(animate);
controls.update();
locations.value.forEach((location) => {
const position = latLngToVector3(location.lat, location.lng);
const vector = position.clone();
vector.applyMatrix4(globe.matrixWorld);
const coords = vector.project(camera);
const screenPosition = {
x: (coords.x * 0.5 + 0.5) * container.value.clientWidth,
y: (-coords.y * 0.5 + 0.5) * container.value.clientHeight,
};
location.screenPosition = screenPosition;
});
renderer.render(scene, camera);
};
const latLngToVector3 = (lat, lng) => {
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lng + 180) * (Math.PI / 180);
const radius = 5;
return new THREE.Vector3(
-radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta),
);
};
const handleResize = () => {
if (!container.value) return;
camera.aspect = container.value.clientWidth / container.value.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
};
onMounted(() => {
init();
animate();
window.addEventListener("resize", handleResize);
setTimeout(() => {
showLabels.value = true;
}, 1000);
});
onUnmounted(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
window.removeEventListener("resize", handleResize);
if (renderer) {
renderer.dispose();
}
if (container.value) {
container.value.innerHTML = "";
}
});
</script>
<style scoped>
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0.3);
}
70% {
box-shadow: 0 0 0 4px rgba(27, 217, 106, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0);
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.center-on-top-left {
transform: translate(-50%, -50%);
}
.expanding-item.expanded {
grid-template-columns: 1fr;
}
@media (hover: hover) {
.location-button:hover .expanding-item {
grid-template-columns: 1fr;
}
}
.expanding-item {
display: grid;
grid-template-columns: 0fr;
transition: grid-template-columns 0.15s ease-in-out;
overflow: hidden;
> div {
overflow: hidden;
}
}
@media (prefers-reduced-motion) {
.expanding-item {
transition: none !important;
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="ticker-container">
<div class="ticker-content">
<div
v-for="(message, index) in msgs"
:key="message"
class="ticker-item text-xs"
:class="{ active: index === currentIndex % msgs.length }"
>
{{ message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const msgs = [
"Organizing files...",
"Downloading mods...",
"Configuring server...",
"Setting up environment...",
"Adding Java...",
];
const currentIndex = ref(0);
let intervalId: NodeJS.Timeout | null = null;
onMounted(() => {
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % msgs.length;
}, 3000);
});
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
</script>
<style scoped>
.ticker-container {
height: 20px;
width: 100%;
position: relative;
}
.ticker-content {
position: relative;
width: 100%;
}
.ticker-item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
display: flex;
align-items: center;
color: var(--color-secondary-text);
opacity: 0;
transform: scale(0.9);
filter: blur(4px);
transition: all 0.3s ease-in-out;
}
.ticker-item.active {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
</style>

View File

@@ -10,6 +10,7 @@
:is-current="isCurrentLoader(loader.name)" :is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version" :loader-version="data.loader_version"
:current-loader="data.loader" :current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader" @select="selectLoader"
/> />
</div> </div>
@@ -28,6 +29,7 @@
:is-current="isCurrentLoader(loader.name)" :is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version" :loader-version="data.loader_version"
:current-loader="data.loader" :current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader" @select="selectLoader"
/> />
</div> </div>
@@ -47,6 +49,7 @@
:is-current="isCurrentLoader(loader.name)" :is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version" :loader-version="data.loader_version"
:current-loader="data.loader" :current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader" @select="selectLoader"
/> />
</div> </div>
@@ -60,6 +63,7 @@ const props = defineProps<{
loader: string | null; loader: string | null;
loader_version: string | null; loader_version: string | null;
}; };
isInstalling?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -31,7 +31,7 @@
</div> </div>
<ButtonStyled> <ButtonStyled>
<button @click="onSelect"> <button :disabled="isInstalling" @click="onSelect">
<DownloadIcon class="h-5 w-5" /> <DownloadIcon class="h-5 w-5" />
{{ isCurrentLoader ? "Reinstall" : "Install" }} {{ isCurrentLoader ? "Reinstall" : "Install" }}
</button> </button>
@@ -52,6 +52,7 @@ interface Props {
loader: LoaderInfo; loader: LoaderInfo;
currentLoader: string | null; currentLoader: string | null;
loaderVersion: string | null; loaderVersion: string | null;
isInstalling?: boolean;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();

View File

@@ -0,0 +1,91 @@
<template>
<div
class="parsed-log relative flex h-8 w-full items-center overflow-hidden rounded-lg px-6"
@mouseenter="checkOverflow"
@touchstart="checkOverflow"
>
<div ref="logContent" class="log-content flex-1 truncate whitespace-pre">
<span v-html="sanitizedLog"></span>
</div>
<button
v-if="isOverflowing"
class="ml-2 flex h-6 items-center rounded-md bg-bg px-2 text-xs text-contrast opacity-50 transition-opacity hover:opacity-100"
type="button"
@click.stop="$emit('show-full-log', props.log)"
>
...
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import Convert from "ansi-to-html";
import DOMPurify from "dompurify";
const props = defineProps<{
log: string;
}>();
defineEmits<{
"show-full-log": [log: string];
}>();
const logContent = ref<HTMLElement | null>(null);
const isOverflowing = ref(false);
const checkOverflow = () => {
if (logContent.value && !isOverflowing.value) {
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth;
}
};
const convert = new Convert({
fg: "#FFF",
bg: "#000",
newline: false,
escapeXML: true,
stream: false,
});
const sanitizedLog = computed(() =>
DOMPurify.sanitize(convert.toHtml(props.log), {
ALLOWED_TAGS: ["span"],
ALLOWED_ATTR: ["style"],
USE_PROFILES: { html: true },
}),
);
const preventSelection = (e: MouseEvent) => {
e.preventDefault();
};
onMounted(() => {
logContent.value?.addEventListener("mousedown", preventSelection);
});
onUnmounted(() => {
logContent.value?.removeEventListener("mousedown", preventSelection);
});
</script>
<style scoped>
.parsed-log {
background: transparent;
transition: background-color 0.1s;
}
.parsed-log:hover {
background: rgba(128, 128, 128, 0.25);
transition: 0s;
}
.log-content > span {
user-select: none;
white-space: pre;
}
.log-content {
white-space: pre;
}
</style>

View File

@@ -1,107 +0,0 @@
<template>
<div class="parsed-log group relative w-full overflow-hidden px-6 py-1">
<div
ref="logContent"
class="log-content selectable whitespace-pre-wrap selection:bg-black selection:text-white dark:selection:bg-white dark:selection:text-black"
v-html="sanitizedLog"
></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import Convert from "ansi-to-html";
import DOMPurify from "dompurify";
const props = defineProps<{
log: string;
index: number;
}>();
const logContent = ref<HTMLElement | null>(null);
const colors = {
30: "#101010",
31: "#EFA6A2",
32: "#80C990",
33: "#A69460",
34: "#A3B8EF",
35: "#E6A3DC",
36: "#50CACD",
37: "#808080",
90: "#454545",
91: "#E0AF85",
92: "#5ACCAF",
93: "#C8C874",
94: "#CCACED",
95: "#F2A1C2",
96: "#74C3E4",
97: "#C0C0C0",
};
const convert = new Convert({
fg: "#FFF",
bg: "#000",
newline: false,
escapeXML: true,
stream: false,
colors,
});
const urlRegex = /https?:\/\/[^\s]+/g;
const usernameRegex = /&lt;([^&]+)&gt;/g;
const sanitizedLog = computed(() => {
let html = convert.toHtml(props.log);
html = html.replace(
urlRegex,
(url) =>
`<a style="color:var(--color-link);text-decoration:underline;" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`,
);
html = html.replace(
usernameRegex,
(_, username) => `<span class="minecraft-username">&lt;${username}&gt;</span>`,
);
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["span", "a"],
ALLOWED_ATTR: ["style", "href", "target", "rel", "class"],
ADD_ATTR: ["target"],
RETURN_TRUSTED_TYPE: true,
USE_PROFILES: { html: true },
});
});
</script>
<style scoped>
.parsed-log:hover:not(.selected) {
border-radius: 0.5rem;
}
html.light-mode .parsed-log:hover:not(.selected) {
background-color: #ccc;
}
html.dark-mode .parsed-log:hover:not(.selected) {
background-color: #222;
}
html.oled-mode .parsed-log:hover:not(.selected) {
background-color: #222;
}
.minecraft-username {
font-weight: bold;
}
::v-deep(.log-content) {
user-select: none;
}
::v-deep(.log-content.selectable) {
user-select: text;
}
::v-deep(.log-content *) {
user-select: text;
}
</style>

View File

@@ -34,8 +34,7 @@
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100" class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
> >
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0 Bytes</h2> <h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">0 B</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 0 Bytes</h3>
</div> </div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3> <h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" /> <FolderOpenIcon class="absolute right-10 top-10 size-8" />
@@ -50,8 +49,12 @@
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2> <h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
</div> </div>
</div> </div>
<div class="relative w-full">
<input type="text" placeholder="Search logs" class="h-12 !w-full !pl-10 !pr-48" />
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
</div>
<div <div
class="console relative h-full min-h-[488px] w-full overflow-hidden rounded-xl bg-bg text-sm" class="console relative h-full min-h-[516px] w-full overflow-hidden rounded-xl bg-bg text-sm"
></div> ></div>
</div> </div>
</div> </div>
@@ -59,7 +62,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CPUIcon, DBIcon, FolderOpenIcon } from "@modrinth/assets"; import { CPUIcon, DBIcon, FolderOpenIcon, SearchIcon } from "@modrinth/assets";
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,31 +0,0 @@
<template>
<ButtonStyled type="standard">
<button aria-label="Copy server IP" @click="copyText">
<CopyIcon />
Copy IP
</button>
</ButtonStyled>
</template>
<script setup lang="ts">
import { CopyIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
const props = defineProps<{
ip: string;
port: number;
subdomain?: string | null;
}>();
const copyText = () => {
const text = props.subdomain ? `${props.subdomain}.modrinth.gg` : `${props.ip}:${props.port}`;
navigator.clipboard.writeText(text);
addNotification({
group: "server",
title: `Copied IP`,
text: `Your server's IP has been copied to your clipboard`,
type: "success",
});
};
</script>

View File

@@ -1,23 +1,25 @@
<template> <template>
<div class="contents"> <div class="contents">
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal"> <NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
<div class="flex flex-col gap-4 md:w-[400px]"> <div class="flex flex-col gap-4 md:w-[400px]">
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p> <p class="m-0">
Are you sure you want to <span class="lowercase">{{ confirmActionText }}</span> the
server?
</p>
<UiCheckbox <UiCheckbox
v-model="powerDontAskAgainCheckbox" v-model="dontAskAgain"
label="Don't ask me again" label="Don't ask me again"
class="text-sm" class="text-sm"
:disabled="!currentPendingAction" :disabled="!powerAction"
/> />
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="confirmAction"> <ButtonStyled type="standard" color="brand" @click="executePowerAction">
<button> <button>
<CheckIcon class="h-5 w-5" /> <CheckIcon class="h-5 w-5" />
{{ currentPendingActionFriendly }} server {{ confirmActionText }} server
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled @click="closePowerModal"> <ButtonStyled @click="resetPowerAction">
<button> <button>
<XIcon class="h-5 w-5" /> <XIcon class="h-5 w-5" />
Cancel Cancel
@@ -29,7 +31,7 @@
<NewModal <NewModal
ref="detailsModal" ref="detailsModal"
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`" :header="`All of ${serverName || 'Server'} info`"
@close="closeDetailsModal" @close="closeDetailsModal"
> >
<UiServersServerInfoLabels <UiServersServerInfoLabels
@@ -51,75 +53,74 @@
<UiServersPanelSpinner class="size-5" /> Installing... <UiServersPanelSpinner class="size-5" /> Installing...
</button> </button>
</ButtonStyled> </ButtonStyled>
<div v-else class="contents">
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent"> <ButtonStyled v-if="showStopButton" type="transparent">
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer"> <button :disabled="!canTakeAction" @click="initiateAction('stop')">
<div class="flex gap-1"> <div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" /> <StopCircleIcon class="h-5 w-5" />
<span>{{ stopButtonText }}</span> <span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
</div> </div>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled type="standard" color="brand"> <ButtonStyled type="standard" color="brand">
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction"> <button :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isStartingOrRestarting" class="grid place-content-center"> <div v-if="isTransitionState" class="grid place-content-center">
<UiServersIconsLoadingIcon /> <UiServersIconsLoadingIcon />
</div> </div>
<div v-else class="contents"> <component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" /> <span>{{ primaryActionText }}</span>
</div>
<span>
{{ actionButtonText }}
</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
</div>
<!-- Dropdown options --> <ButtonStyled circular type="transparent">
<ButtonStyled circular type="transparent"> <UiServersTeleportOverflowMenu :options="[...menuOptions]">
<UiServersTeleportOverflowMenu <MoreVerticalIcon aria-hidden="true" />
:options="[ <template #kill>
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]), <SlashIcon class="h-5 w-5" />
{ id: 'allServers', action: () => router.push('/servers/manage') }, <span>Kill server</span>
{ id: 'details', action: () => showDetailsModal() }, </template>
]" <template #allServers>
> <ServerIcon class="h-5 w-5" />
<MoreVerticalIcon aria-hidden="true" /> <span>All servers</span>
<template #kill> </template>
<SlashIcon class="h-5 w-5" /> <template #details>
<span>Kill server</span> <InfoIcon class="h-5 w-5" />
</template> <span>Details</span>
<template #allServers> </template>
<ServerIcon class="h-5 w-5" /> </UiServersTeleportOverflowMenu>
<span>All servers</span> </ButtonStyled>
</template> </template>
<template #details>
<InfoIcon class="h-5 w-5" />
<span>Details</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from "vue"; import { ref, computed } from "vue";
import { import {
PlayIcon, PlayIcon,
UpdatedIcon, UpdatedIcon,
StopCircleIcon, StopCircleIcon,
SlashIcon, SlashIcon,
MoreVerticalIcon,
XIcon, XIcon,
CheckIcon, CheckIcon,
ServerIcon, ServerIcon,
InfoIcon, InfoIcon,
MoreVerticalIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui"; import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useStorage } from "@vueuse/core"; import { useStorage } from "@vueuse/core";
type ServerAction = "start" | "stop" | "restart" | "kill";
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
interface PowerAction {
action: ServerAction;
nextState: ServerState;
}
const props = defineProps<{ const props = defineProps<{
isOnline: boolean; isOnline: boolean;
isActioning: boolean; isActioning: boolean;
@@ -130,183 +131,142 @@ const props = defineProps<{
uptimeSeconds: number; uptimeSeconds: number;
}>(); }>();
const emit = defineEmits<{
(e: "action", action: ServerAction): void;
}>();
const router = useRouter(); const router = useRouter();
const serverId = router.currentRoute.value.params.id; const serverId = router.currentRoute.value.params.id;
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, { const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false, powerDontAskAgain: false,
}); });
const emit = defineEmits<{ const serverState = ref<ServerState>(props.isOnline ? "running" : "stopped");
(e: "action", action: "start" | "restart" | "stop" | "kill"): void; const powerAction = ref<PowerAction | null>(null);
}>(); const dontAskAgain = ref(false);
const startingDelay = ref(false);
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const ServerState = {
Stopped: "Stopped",
Starting: "Starting",
Running: "Running",
Stopping: "Stopping",
Restarting: "Restarting",
} as const;
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
const currentPendingAction = ref<string | null>(null);
const currentPendingState = ref<ServerStateType | null>(null);
const powerDontAskAgainCheckbox = ref(false);
const currentState = ref<ServerStateType>(
props.isOnline ? ServerState.Running : ServerState.Stopped,
);
const isStartingDelay = ref(false);
const showStopButton = computed(
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
);
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
const canTakeAction = computed( const canTakeAction = computed(
() => () => !props.isActioning && !startingDelay.value && !isTransitionState.value,
!props.isActioning &&
!isStartingDelay.value &&
currentState.value !== ServerState.Starting &&
currentState.value !== ServerState.Stopping,
); );
const isRunning = computed(() => serverState.value === "running");
const isStartingOrRestarting = computed( const isTransitionState = computed(() =>
() => ["starting", "stopping", "restarting"].includes(serverState.value),
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
); );
const isStoppingState = computed(() => serverState.value === "stopping");
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
const isStopping = computed(() => currentState.value === ServerState.Stopping); const primaryActionText = computed(() => {
const states: Record<ServerState, string> = {
const actionButtonText = computed(() => { starting: "Starting...",
switch (currentState.value) { restarting: "Restarting...",
case ServerState.Starting: running: "Restart",
return "Starting..."; stopping: "Stopping...",
case ServerState.Restarting: stopped: "Start",
return "Restarting..."; };
case ServerState.Running: return states[serverState.value];
return "Restart";
case ServerState.Stopping:
return "Stopping...";
default:
return "Start";
}
}); });
const currentPendingActionFriendly = computed(() => { const confirmActionText = computed(() => {
switch (currentPendingAction.value) { if (!powerAction.value) return "";
case "start": return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1);
return "Start";
case "restart":
return "Restart";
case "stop":
return "Stop";
case "kill":
return "Kill";
default:
return null;
}
}); });
const stopButtonText = computed(() => const menuOptions = computed(() => [
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop", ...(props.isInstalling
); ? []
: [
{
id: "kill",
label: "Kill server",
icon: SlashIcon,
action: () => initiateAction("kill"),
},
]),
{
id: "allServers",
label: "All servers",
icon: ServerIcon,
action: () => router.push("/servers/manage"),
},
{
id: "details",
label: "Details",
icon: InfoIcon,
action: () => detailsModal.value?.show(),
},
]);
const createPendingAction = () => { function initiateAction(action: ServerAction) {
if (!canTakeAction.value) return; if (!canTakeAction.value) return;
if (currentState.value === ServerState.Running) {
currentPendingAction.value = "restart"; const stateMap: Record<ServerAction, ServerState> = {
currentPendingState.value = ServerState.Restarting; start: "starting",
showPowerModal(); stop: "stopping",
} else { restart: "restarting",
runAction("start", ServerState.Starting); kill: "stopping",
};
if (action === "start") {
emit("action", action);
serverState.value = stateMap[action];
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
return;
} }
};
const handleAction = () => { powerAction.value = { action, nextState: stateMap[action] };
createPendingAction();
};
const showPowerModal = () => {
if (userPreferences.value.powerDontAskAgain) { if (userPreferences.value.powerDontAskAgain) {
runAction( executePowerAction();
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
} else { } else {
confirmActionModal.value?.show(); confirmActionModal.value?.show();
} }
}; }
const confirmAction = () => { function handlePrimaryAction() {
if (powerDontAskAgainCheckbox.value) { initiateAction(isRunning.value ? "restart" : "start");
}
function executePowerAction() {
if (!powerAction.value) return;
const { action, nextState } = powerAction.value;
emit("action", action);
serverState.value = nextState;
if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true; userPreferences.value.powerDontAskAgain = true;
} }
runAction(
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
closePowerModal();
};
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
emit("action", action);
currentState.value = serverState;
if (action === "start") { if (action === "start") {
isStartingDelay.value = true; startingDelay.value = true;
setTimeout(() => { setTimeout(() => (startingDelay.value = false), 5000);
isStartingDelay.value = false;
}, 5000);
} }
};
const stopServer = () => { resetPowerAction();
if (!canTakeAction.value) return; }
currentPendingAction.value = "stop";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
const killServer = () => { function resetPowerAction() {
currentPendingAction.value = "kill";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
const closePowerModal = () => {
confirmActionModal.value?.hide(); confirmActionModal.value?.hide();
currentPendingAction.value = null; powerAction.value = null;
powerDontAskAgainCheckbox.value = false; dontAskAgain.value = false;
}; }
const closeDetailsModal = () => { function closeDetailsModal() {
detailsModal.value?.hide(); detailsModal.value?.hide();
}; }
const showDetailsModal = () => {
detailsModal.value?.show();
};
watch( watch(
() => props.isOnline, () => props.isOnline,
(newValue) => { (online) => (serverState.value = online ? "running" : "stopped"),
if (newValue) {
currentState.value = ServerState.Running;
} else {
currentState.value = ServerState.Stopped;
}
},
); );
watch( watch(
() => router.currentRoute.value.fullPath, () => router.currentRoute.value.fullPath,
() => { () => closeDetailsModal(),
closeDetailsModal();
},
); );
</script> </script>

View File

@@ -1,66 +1,72 @@
<template> <template>
<div <div
:aria-label="`Server is ${getStatusText}`" :aria-label="`Server is ${getStatusText(state)}`"
class="relative inline-flex select-none items-center" class="relative inline-flex select-none items-center"
@mouseenter="isExpanded = true" @mouseenter="isExpanded = true"
@mouseleave="isExpanded = false" @mouseleave="isExpanded = false"
> >
<div <div
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`" :class="[
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
getStatusClass(state).main,
]"
> >
<div <div
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`" :class="[
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
getStatusClass(state).bg,
]"
></div> ></div>
</div> </div>
<div <div
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${ :class="[
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0' 'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
}`" getStatusClass(state).bg,
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
]"
> >
<div class="h-3 w-3 rounded-full"></div> <div class="h-3 w-3 rounded-full"></div>
<span <span
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out" :class="[
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`" 'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
]"
> >
{{ getStatusText }} {{ getStatusText(state) }}
</span> </span>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { ref } from "vue";
import type { ServerState } from "~/types/servers"; import type { ServerState } from "~/types/servers";
const props = defineProps<{ const STATUS_CLASSES = {
running: { main: "bg-brand", bg: "bg-bg-green" },
stopped: { main: "", bg: "" },
crashed: { main: "bg-brand-red", bg: "bg-bg-red" },
unknown: { main: "", bg: "" },
} as const;
const STATUS_TEXTS = {
running: "Running",
stopped: "",
crashed: "Crashed",
unknown: "Unknown",
} as const;
defineProps<{
state: ServerState; state: ServerState;
}>(); }>();
const isExpanded = ref(false); const isExpanded = ref(false);
const getStatusClass = computed(() => { function getStatusClass(state: ServerState) {
switch (props.state) { return STATUS_CLASSES[state] ?? STATUS_CLASSES.unknown;
case "running": }
return { main: "bg-brand", bg: "bg-bg-green" };
case "stopped":
return { main: "", bg: "" };
case "crashed":
return { main: "bg-brand-red", bg: "bg-bg-red" };
default:
return { main: "", bg: "" };
}
});
const getStatusText = computed(() => { function getStatusText(state: ServerState) {
switch (props.state) { return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown;
case "running": }
return "Running";
case "stopped":
return "";
case "crashed":
return "Crashed";
default:
return "Unknown";
}
});
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,164 @@
<template>
<NewModal
ref="modal"
:header="'Changing ' + props.project?.title + ' version'"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<div class="flex flex-col gap-2">
<p class="m-0">
Select the version of {{ props.project?.title || "the modpack" }} you want to install on
your server.
</p>
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
Currently installed: {{ props.currentVersion.version_number }}
</p>
</div>
<div class="flex w-full flex-col gap-4">
<UiServersTeleportDropdownMenu
v-if="props.versions?.length"
v-model="selectedVersion"
:options="versionOptions"
placeholder="Select version..."
name="version"
class="w-full max-w-full"
/>
<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="modpack-hard-reset">
Erase all data
</label>
<input
id="modpack-hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the new modpack version.
</div>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="hardReset ? 'red' : 'brand'">
<button
:disabled="isLoading || !selectedVersion || props.serverStatus === 'installing'"
@click="handleReinstall"
>
<DownloadIcon class="size-4" />
{{ isLoading ? "Installing..." : hardReset ? "Erase and install" : "Install" }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isLoading" @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { DownloadIcon, XIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
project: any;
versions: any[];
currentVersion?: any;
currentVersionId?: string;
serverStatus?: string;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const modal = ref();
const hardReset = ref(false);
const isLoading = ref(false);
const selectedVersion = ref("");
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || []);
const handleReinstall = async () => {
if (!selectedVersion.value || !props.project?.id) return;
isLoading.value = true;
try {
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id;
await props.server.general?.reinstall(
props.server.serverId,
false,
props.project.id,
versionId,
undefined,
hardReset.value,
);
emit("reinstall");
hide();
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall 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.",
type: "error",
});
}
} finally {
isLoading.value = false;
}
};
watch(
() => props.serverStatus,
(newStatus) => {
if (newStatus === "installing") {
hide();
}
},
);
const onShow = () => {
hardReset.value = false;
selectedVersion.value =
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? "";
};
const onHide = () => {
hardReset.value = false;
selectedVersion.value = "";
isLoading.value = false;
};
const show = () => modal.value?.show();
const hide = () => modal.value?.hide();
defineExpose({ show, hide });
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @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',
}"
>
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>
<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>
</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>
<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="backup-server-mrpack">
Backup server
</label>
<input
id="backup-server-mrpack"
v-model="backupServer"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>Creates a backup of your server before proceeding.</div>
</div>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button :disabled="canInstall" @click="handleReinstall">
<RightArrowIcon />
{{
isBackingUp
? "Backing up..."
: isMrpackModalSecondPhase
? "Erase and install"
: 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>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const mrpackModal = ref();
const isMrpackModalSecondPhase = ref(false);
const hardReset = ref(false);
const backupServer = ref(false);
const isLoading = ref(false);
const isBackingUp = ref(false);
const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null);
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
const uploadMrpack = (event: Event) => {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) {
return;
}
mrpackFile.value = target.files[0];
};
const performBackup = async (): Promise<boolean> => {
try {
const date = new Date();
const format = date.toLocaleString(navigator.language || "en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
});
const backupName = `Reinstallation - ${format}`;
isLoading.value = true;
const backupId = await props.server.backups?.create(backupName);
isBackingUp.value = true;
let attempts = 0;
while (true) {
attempts++;
if (attempts > 100) {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
await props.server.refresh(["backups"]);
const backups = await props.server.backups?.data;
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
if (backup && !backup.ongoing) {
isBackingUp.value = false;
break;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return true;
} catch {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
};
const handleReinstall = async () => {
if (hardReset.value && !backupServer.value && !isMrpackModalSecondPhase.value) {
isMrpackModalSecondPhase.value = true;
return;
}
if (backupServer.value && !(await performBackup())) {
isLoading.value = false;
return;
}
isLoading.value = true;
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);
emit("reinstall", {
loader: "mrpack",
lVersion: "",
mVersion: "",
});
await nextTick();
window.scrollTo(0, 0);
hide();
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall 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.",
type: "error",
});
}
} finally {
isLoading.value = false;
}
};
const onShow = () => {
hardReset.value = false;
backupServer.value = false;
isMrpackModalSecondPhase.value = false;
loadingServerCheck.value = false;
isLoading.value = false;
mrpackFile.value = null;
};
const onHide = () => {
onShow();
};
const show = () => mrpackModal.value?.show();
const hide = () => mrpackModal.value?.hide();
defineExpose({ show, hide });
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -0,0 +1,551 @@
<template>
<NewModal
ref="versionSelectModal"
:header="
isSecondPhase
? 'Confirming reinstallation'
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<p
v-if="isSecondPhase"
:style="{
lineHeight: isSecondPhase ? '1.5' : undefined,
marginBottom: isSecondPhase ? '-12px' : '0',
marginTop: isSecondPhase ? '-4px' : '-2px',
}"
>
{{
backupServer
? "A backup will be created before proceeding with the reinstallation, then all data will be erased from your server. Are you sure you want to continue?"
: "This will reinstall your server and erase all data. Are you sure you want to continue?"
}}
</p>
<div v-if="!isSecondPhase" 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"
>
<UiServersIconsLoaderIcon class="size-10" :loader="selectedLoader" />
</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>
</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">Minecraft version</div>
<UiServersTeleportDropdownMenu
v-model="selectedMCVersion"
name="mcVersion"
:options="mcVersions"
class="w-full max-w-[100%]"
placeholder="Select Minecraft version..."
/>
</div>
<div
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
class="flex w-full flex-col gap-2 rounded-2xl p-4"
:class="{
'bg-table-alternateRow':
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
'bg-highlight-red':
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
}"
>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-contrast">{{ selectedLoader }} version</div>
<template v-if="!selectedMCVersion">
<div
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
>
Select a Minecraft version to see available versions
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="isLoading">
<div
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
>
<UiServersIconsLoadingIcon class="mr-2 animate-spin" />
Loading versions...
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="selectedLoaderVersions.length > 0">
<UiServersTeleportDropdownMenu
v-model="selectedLoaderVersion"
name="loaderVersion"
:options="selectedLoaderVersions"
class="w-full max-w-[100%]"
:placeholder="
selectedLoader.toLowerCase() === 'paper' ||
selectedLoader.toLowerCase() === 'purpur'
? `Select build number...`
: `Select loader version...`
"
/>
</template>
<template v-else>
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
</template>
</div>
</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>
<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="backup-server">
Backup server
</label>
<input
id="backup-server"
v-model="backupServer"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Creates a backup of your server before proceeding with the installation or
reinstallation.
</div>
</div>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button :disabled="canInstall" @click="handleReinstall">
<RightArrowIcon />
{{
isBackingUp
? "Backing up..."
: isLoading
? "Installing..."
: isSecondPhase
? "Erase and install"
: hardReset
? "Continue"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
if (isSecondPhase) {
isSecondPhase = false;
} else {
hide();
}
"
>
<XIcon />
{{ isSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers";
interface LoaderVersion {
id: string;
stable: boolean;
loaders: {
id: string;
url: string;
stable: boolean;
}[];
}
type VersionMap = Record<string, LoaderVersion[]>;
type VersionCache = Record<string, any>;
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
currentLoader: Loaders | undefined;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const versionSelectModal = ref();
const isSecondPhase = ref(false);
const hardReset = ref(false);
const backupServer = ref(false);
const isLoading = ref(false);
const isBackingUp = ref(false);
const loadingServerCheck = ref(false);
const serverCheckError = ref("");
const selectedLoader = ref<Loaders>("Vanilla");
const selectedMCVersion = ref("");
const selectedLoaderVersion = ref("");
const paperVersions = ref<Record<string, number[]>>({});
const purpurVersions = ref<Record<string, string[]>>({});
const loaderVersions = ref<VersionMap>({});
const cachedVersions = ref<VersionCache>({});
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
const fetchLoaderVersions = async () => {
const versions = await Promise.all(
versionStrings.map(async (loader) => {
const runFetch = async (iterations: number) => {
if (iterations > 5) {
throw new Error("Failed to fetch loader versions");
}
try {
const res = await $fetch(`/loader-versions?loader=${loader}`);
return { [loader]: (res as any).gameVersions };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
return await runFetch(iterations + 1);
}
};
try {
return await runFetch(0);
} catch (e) {
console.error(e);
return { [loader]: [] };
}
}),
);
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {});
};
const fetchPaperVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`);
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a);
return res;
} catch (e) {
console.error(e);
return null;
}
};
const fetchPurpurVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`);
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
(a: string, b: string) => parseInt(b) - parseInt(a),
);
return res;
} catch (e) {
console.error(e);
return null;
}
};
const selectedLoaderVersions = computed(() => {
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper") {
return paperVersions.value[selectedMCVersion.value] || [];
}
if (loader === "purpur") {
return purpurVersions.value[selectedMCVersion.value] || [];
}
if (loader === "vanilla") {
return [];
}
let apiLoader = loader;
if (loader === "neoforge") {
apiLoader = "neo";
}
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
// eslint-disable-next-line no-template-curly-in-string
(x) => x.id === "${modrinth.gameVersion}",
);
if (backwardsCompatibleVersion) {
return backwardsCompatibleVersion.loaders.map((x) => x.id);
}
return (
loaderVersions.value[apiLoader]
?.find((x) => x.id === selectedMCVersion.value)
?.loaders.map((x) => x.id) || []
);
});
watch(selectedLoader, async () => {
if (selectedMCVersion.value) {
selectedLoaderVersion.value = "";
serverCheckError.value = "";
await checkVersionAvailability(selectedMCVersion.value);
}
});
watch(
selectedLoaderVersions,
(newVersions) => {
if (newVersions.length > 0 && !selectedLoaderVersion.value) {
selectedLoaderVersion.value = String(newVersions[0]);
}
},
{ immediate: true },
);
const checkVersionAvailability = async (version: string) => {
if (!version || version.trim().length < 3) return;
isLoading.value = true;
loadingServerCheck.value = true;
try {
const mcRes =
cachedVersions.value[version] ||
(await $fetch(`/loader-versions?loader=minecraft&version=${version}`));
cachedVersions.value[version] = mcRes;
if (!mcRes.downloads?.server) {
serverCheckError.value = "We couldn't find a server.jar for this version.";
return;
}
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper" || loader === "purpur") {
const fetchFn = loader === "paper" ? fetchPaperVersions : fetchPurpurVersions;
const result = await fetchFn(version);
if (!result) {
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`;
return;
}
}
serverCheckError.value = "";
} catch (error) {
console.error(error);
serverCheckError.value = "Failed to fetch versions.";
} finally {
loadingServerCheck.value = false;
isLoading.value = false;
}
};
watch(selectedMCVersion, checkVersionAvailability);
onMounted(() => {
fetchLoaderVersions();
});
const tags = useTags();
const mcVersions = tags.value.gameVersions
.filter((x) => x.version_type === "release")
.map((x) => x.version)
.filter((x) => {
const segment = parseInt(x.split(".")[1], 10);
return !isNaN(segment) && segment > 2;
});
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => {
const conds =
!selectedMCVersion.value ||
isLoading.value ||
loadingServerCheck.value ||
serverCheckError.value.trim().length > 0;
if (selectedLoader.value.toLowerCase() === "vanilla") {
return conds;
}
return conds || !selectedLoaderVersion.value;
});
const performBackup = async (): Promise<boolean> => {
try {
const date = new Date();
const format = date.toLocaleString(navigator.language || "en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
});
const backupName = `Reinstallation - ${format}`;
isLoading.value = true;
const backupId = await props.server.backups?.create(backupName);
isBackingUp.value = true;
let attempts = 0;
while (true) {
attempts++;
if (attempts > 100) {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
await props.server.refresh(["backups"]);
const backups = await props.server.backups?.data;
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
if (backup && !backup.ongoing) {
isBackingUp.value = false;
break;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return true;
} catch {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
};
const handleReinstall = async () => {
if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true;
return;
}
if (backupServer.value) {
isBackingUp.value = true;
if (!(await performBackup())) {
isBackingUp.value = false;
isLoading.value = false;
return;
}
isBackingUp.value = false;
}
isLoading.value = true;
try {
await props.server.general?.reinstall(
props.server.serverId,
true,
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
hardReset.value,
);
emit("reinstall", {
loader: selectedLoader.value,
lVersion: selectedLoaderVersion.value,
mVersion: selectedMCVersion.value,
});
hide();
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall 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.",
type: "error",
});
}
} finally {
isLoading.value = false;
}
};
const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || "";
};
const onHide = () => {
hardReset.value = false;
backupServer.value = false;
isSecondPhase.value = false;
serverCheckError.value = "";
loadingServerCheck.value = false;
isLoading.value = false;
selectedMCVersion.value = "";
serverCheckError.value = "";
paperVersions.value = {};
purpurVersions.value = {};
};
const show = (loader: Loaders) => {
if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = "";
}
selectedLoader.value = loader;
selectedMCVersion.value = props.server.general?.mc_version || "";
versionSelectModal.value?.show();
};
const hide = () => versionSelectModal.value?.hide();
defineExpose({ show, hide });
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -1,167 +0,0 @@
<template>
<div class="flex h-[400px] w-full max-w-xl flex-col overflow-hidden">
<div class="iconified-input mb-4 w-full">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="queryFilter"
name="search"
type="search"
:placeholder="`Search ${props.type}s...`"
autocomplete="off"
@keyup.enter="resetList"
/>
</div>
<div class="flex h-full w-full flex-col">
<div
v-if="mods && mods.hits.length > 0"
ref="scrollContainer"
class="flex h-full w-full flex-col gap-2 overflow-y-scroll"
>
<div v-for="mod in mods.hits" :key="mod.title" class="rounded-lg px-2 py-2 hover:bg-bg">
<div class="flex cursor-pointer gap-2" @click="toggleMod(mod.project_id)">
<UiAvatar :src="mod.icon_url" class="!h-12 !min-h-12 !w-12 !min-w-12" />
<div class="flex flex-col gap-1">
<h1 class="m-0 text-2xl font-bold leading-none text-contrast">
{{ mod.title }}
</h1>
<span class="text-sm text-secondary">
{{ mod.description.substring(0, 100) }}
{{ mod.description.length > 100 ? "..." : "" }}
</span>
</div>
</div>
<div v-if="expandedMods[mod.project_id]" class="mt-2 flex items-center gap-2">
<DropdownSelect
id="version-select"
v-model="selectedVersions[mod.project_id]"
name="version-select"
:options="expandedMods[mod.project_id].versions"
placeholder="Select version..."
/>
<Button icon-only @click="emits('select', mod, selectedVersions[mod.project_id])">
<ChevronRightIcon />
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon, SearchIcon } from "@modrinth/assets";
import { Button, DropdownSelect } from "@modrinth/ui";
import { useInfiniteScroll } from "@vueuse/core";
const emits = defineEmits(["select"]);
const props = defineProps<{
type: "mod" | "modpack" | "plugin" | "datapack";
isserver?: boolean;
}>();
const route = useNativeRoute();
const serverId = route.params.id as string;
const server = serverId ? await usePyroServer(serverId, ["general"]) : null;
const data = computed(() => (serverId ? server?.general : null));
const scrollContainer = ref<HTMLElement | null>(null);
const pages = ref(1);
const page = ref(0);
const queryFilter = ref("");
const facets = ref<any>([]);
if (props.isserver === false && props.type !== "modpack") {
facets.value.push(`["categories:${data.value?.loader?.toLocaleLowerCase()}"]`);
facets.value.push(`["versions:${data.value?.mc_version}"]`);
}
facets.value.push(`["project_type:${props.type}"]`);
const buildFacetString = (facets: string[]) => {
return "[" + facets.map((facet) => `${facet}`).join(",") + "]";
};
const mods = ref<any>({ hits: [] });
const modsStatus = ref("idle");
const loadMods = async () => {
modsStatus.value = "loading";
const newMods = (await useBaseFetch(
`search?query=${queryFilter.value}&facets=${buildFacetString(facets.value)}&index=relevance&limit=25&offset=${page.value * 25}`,
{},
false,
)) as any;
pages.value = newMods.total_hits;
mods.value.hits.push(...newMods.hits);
modsStatus.value = "success";
};
const versions = reactive<{ [key: string]: any[] }>({});
const getVersions = async (projectId: string) => {
if (!versions[projectId]) {
const allVersions = (await useBaseFetch(`project/${projectId}/version`, {}, false)) as any;
if (props.isserver === false && props.type !== "modpack") {
versions[projectId] = allVersions
.filter((x: any) => x.loaders.includes(data.value?.loader?.toLocaleLowerCase()))
.filter((x: any) => x.game_versions.includes(data.value?.mc_version))
.map((x: any) => x.version_number);
} else {
versions[projectId] = allVersions.map((x: any) => x.version_number);
}
}
return versions[projectId];
};
const selectedVersions = reactive<{ [key: string]: string }>({});
const expandedMods = reactive<{ [key: string]: { expanded: boolean; versions: any[] } }>({});
const toggleMod = async (modId: string) => {
if (!expandedMods[modId]) {
expandedMods[modId] = { expanded: false, versions: [] };
}
expandedMods[modId].expanded = !expandedMods[modId].expanded;
if (expandedMods[modId].expanded && expandedMods[modId].versions.length === 0) {
expandedMods[modId].versions = await getVersions(modId);
// Select the first version by default
if (expandedMods[modId].versions.length > 0) {
selectedVersions[modId] = expandedMods[modId].versions[0];
}
}
};
const loadMore = async () => {
page.value++;
await loadMods();
};
const { reset } = useInfiniteScroll(scrollContainer, async () => {
if (page.value <= pages.value) {
await loadMore();
console.log("loading more");
console.log(page.value);
console.log(pages.value);
}
});
const resetList = () => {
mods.value.hits = [];
Object.keys(expandedMods).forEach((key) => delete expandedMods[key]);
Object.keys(selectedVersions).forEach((key) => delete selectedVersions[key]);
page.value = 0;
loadMods();
reset();
};
onMounted(async () => {
await loadMods();
});
</script>

View File

@@ -1,94 +0,0 @@
<template>
<div class="flex h-[70vh] w-full flex-col items-center justify-center">
<PyroIcon class="pyro-logo-animation size-32 opacity-10" />
<p
class="text-sm transition"
:class="{ 'opacity-0': !showLoading, 'animate-pulse opacity-100': showLoading }"
>
Loading...
</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { PyroIcon } from "@modrinth/assets";
const showLoading = ref(false);
onMounted(() => {
setTimeout(() => {
showLoading.value = true;
}, 5000);
});
</script>
<style>
.page-enter-active,
.page-leave-active {
transition: all 0.1s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}
@keyframes zoom-in {
0% {
transform: scale(0.5);
}
100% {
transform: scale(1);
}
}
.pyro-logo-animation {
animation: zoom-in 0.8s
linear(
0 0%,
0.01 0.8%,
0.04 1.6%,
0.161 3.3%,
0.816 9.4%,
1.046 11.9%,
1.189 14.4%,
1.231 15.7%,
1.254 17%,
1.259 17.8%,
1.257 18.6%,
1.236 20.45%,
1.194 22.3%,
1.057 27%,
0.999 29.4%,
0.955 32.1%,
0.942 33.5%,
0.935 34.9%,
0.933 36.65%,
0.939 38.4%,
1 47.3%,
1.011 49.95%,
1.017 52.6%,
1.016 56.4%,
1 65.2%,
0.996 70.2%,
1.001 87.2%,
1 100%
);
}
@keyframes fade-bg-in {
0% {
opacity: 0;
}
100% {
opacity: 0.6;
}
}
.bg-loading-animation {
animation: fade-bg-in 0.12s linear forwards;
}
</style>

View File

@@ -1,60 +0,0 @@
<template>
<div
class="flex h-full flex-col gap-4 py-6"
:class="
'flex h-full flex-col gap-4 py-6' +
(danger
? ' rounded-2xl border-2 border-solid border-[#cb2245] bg-[#fff5f6] dark:border-[#FF496E] dark:bg-[#270B11]'
: '')
"
>
<div class="mb-2 flex items-center justify-between gap-4 px-6">
<div class="flex w-full items-center gap-4">
<UiServersServerIcon v-if="data" :image="data.image" class="h-12 w-12 rounded-lg" />
<div class="text-2xl font-extrabold text-contrast">{{ props.header }}</div>
</div>
<button
:class="
'h-8 w-8 rounded-full bg-button-bg p-2 text-contrast hover:bg-button-bgActive' +
(danger ? 'hover:bg-[#ffffff20] [&&]:bg-[#ffffff10]' : '')
"
@click="$emit('modal')"
>
<XIcon class="h-4 w-4" />
</button>
</div>
<div
class="border-0 border-b border-solid"
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-divider'"
></div>
<div class="mt-2 h-full w-full overflow-auto px-6">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { XIcon } from "@modrinth/assets";
const emit = defineEmits(["modal"]);
const props = defineProps<{
header?: string;
data?: any;
danger?: boolean;
}>();
const onEscKeyRelease = (event: KeyboardEvent) => {
if (event.key === "Escape") {
emit("modal");
}
};
onMounted(() => {
document.body.addEventListener("keyup", onEscKeyRelease);
});
onBeforeUnmount(() => {
document.removeEventListener("keyup", onEscKeyRelease);
});
</script>

View File

@@ -39,7 +39,7 @@ const props = defineProps<{
save: () => void; save: () => void;
reset: () => void; reset: () => void;
isVisible: boolean; isVisible: boolean;
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>(); }>();
const saveAndRestart = async () => { const saveAndRestart = async () => {

View File

@@ -8,13 +8,19 @@
<NuxtLink <NuxtLink
v-if="isLink" v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''" :to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="min-w-0 truncate text-sm font-semibold" class="flex min-w-0 items-center truncate text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''" :class="serverId ? 'hover:underline' : ''"
> >
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }} <div class="flex flex-row items-center gap-1">
{{ game[0].toUpperCase() + game.slice(1) }}
<span v-if="mcVersion">{{ mcVersion }}</span>
<span v-else class="inline-block h-3 w-12 animate-pulse rounded bg-button-border"></span>
</div>
</NuxtLink> </NuxtLink>
<div v-else class="min-w-0 truncate text-sm font-semibold"> <div v-else class="flex min-w-0 flex-row items-center gap-1 truncate text-sm font-semibold">
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }} {{ game[0].toUpperCase() + game.slice(1) }}
<span v-if="mcVersion">{{ mcVersion }}</span>
<span v-else class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"></span>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -2,19 +2,18 @@
<div> <div>
<UiServersServerGameLabel <UiServersServerGameLabel
v-if="showGameLabel" v-if="showGameLabel"
:game="serverData.game!" :game="serverData.game"
:mc-version="serverData.mc_version ?? ''" :mc-version="serverData.mc_version ?? ''"
:is-link="linked" :is-link="linked"
/> />
<UiServersServerLoaderLabel <UiServersServerLoaderLabel
v-if="showLoaderLabel" :loader="serverData.loader"
:loader="serverData.loader!"
:loader-version="serverData.loader_version ?? ''" :loader-version="serverData.loader_version ?? ''"
:no-separator="column" :no-separator="column"
:is-link="linked" :is-link="linked"
/> />
<UiServersServerSubdomainLabel <UiServersServerSubdomainLabel
v-if="serverData.net.domain" v-if="serverData.net?.domain"
:subdomain="serverData.net.domain" :subdomain="serverData.net.domain"
:no-separator="column" :no-separator="column"
:is-link="linked" :is-link="linked"

View File

@@ -47,7 +47,6 @@
:server-data="{ game, mc_version, loader, loader_version, net }" :server-data="{ game, mc_version, loader, loader_version, net }"
:show-game-label="showGameLabel" :show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel" :show-loader-label="showLoaderLabel"
:show-subdomain-label="showSubdomainLabel"
:linked="false" :linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex" class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/> />
@@ -70,11 +69,13 @@
</div> </div>
<div <div
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'" v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast" class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
> >
<UiServersIconsPanelErrorIcon class="!size-5" /> <div class="flex flex-row gap-2">
Your server has been suspended due to a billing issue. Please visit your billing settings or <UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
contact Modrinth Support for more information. update your billing information or contact Modrinth Support for more information.
</div>
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
</div> </div>
</NuxtLink> </NuxtLink>
</template> </template>
@@ -85,9 +86,12 @@ import type { Project, Server } from "~/types/servers";
const props = defineProps<Partial<Server>>(); const props = defineProps<Partial<Server>>();
if (props.server_id) {
await usePyroServer(props.server_id, ["general"]);
}
const showGameLabel = computed(() => !!props.game); const showGameLabel = computed(() => !!props.game);
const showLoaderLabel = computed(() => !!props.loader); const showLoaderLabel = computed(() => !!props.loader);
const showSubdomainLabel = computed(() => !!props.net?.domain);
let projectData: Ref<Project | null>; let projectData: Ref<Project | null>;
if (props.upstream) { if (props.upstream) {
@@ -103,39 +107,11 @@ if (props.upstream) {
projectData = ref(null); projectData = ref(null);
} }
const image = ref<string | undefined>(); const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
onMounted(async () => { if (import.meta.server && projectData.value?.icon_url) {
const auth = (await usePyroFetch(`servers/${props.server_id}/fs`)) as any; await usePyroServer(props.server_id!, ["general"]);
try { }
const fileData = await usePyroFetch(`/download?path=/server-icon-original.png`, {
override: auth,
});
if (fileData instanceof Blob) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(fileData);
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
image.value = dataURL;
resolve();
};
});
}
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) {
image.value = undefined;
} else {
console.error(error);
}
}
});
const iconUrl = computed(() => projectData.value?.icon_url || undefined); const iconUrl = computed(() => projectData.value?.icon_url || undefined);
</script> </script>

View File

@@ -1,22 +1,33 @@
<template> <template>
<div <div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-4 truncate">
v-if="loader"
v-tooltip="'Change server loader'"
class="flex min-w-0 flex-row items-center gap-4 truncate"
>
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div> <div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<UiServersIconsLoaderIcon :loader="loader" class="flex shrink-0 [&&]:size-5" /> <UiServersIconsLoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
<NuxtLink <NuxtLink
v-if="isLink" v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''" :to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="min-w-0 text-sm font-semibold" class="flex min-w-0 items-center text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''" :class="serverId ? 'hover:underline' : ''"
> >
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span> <span v-if="loader">
{{ loader }}
<span v-if="loaderVersion">{{ loaderVersion }}</span>
</span>
<span v-else class="flex gap-2">
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
</span>
</NuxtLink> </NuxtLink>
<div v-else class="min-w-0 text-sm font-semibold"> <div v-else class="min-w-0 text-sm font-semibold">
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span> <span v-if="loader">
{{ loader }}
<span v-if="loaderVersion">{{ loaderVersion }}</span>
</span>
<span v-else class="flex gap-2">
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -25,8 +36,8 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
noSeparator?: boolean; noSeparator?: boolean;
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla"; loader?: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
loaderVersion: string; loaderVersion?: string;
isLink?: boolean; isLink?: boolean;
}>(); }>();

View File

@@ -36,7 +36,7 @@ const emit = defineEmits(["reinstall"]);
const props = defineProps<{ const props = defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean }[]; navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
route: RouteLocationNormalized; route: RouteLocationNormalized;
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>(); }>();
const onReinstall = (...args: any[]) => { const onReinstall = (...args: any[]) => {

View File

@@ -1,18 +0,0 @@
<template>
<div class="flex flex-col gap-4">
<div
v-for="n in count"
:key="n"
class="relative h-[128px] w-full animate-pulse rounded-3xl bg-bg-raised p-4"
/>
</div>
</template>
<script setup lang="ts">
defineProps({
count: {
type: Number,
default: 3,
},
});
</script>

View File

@@ -9,44 +9,34 @@
:key="index" :key="index"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8" class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
> >
<div <div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1" <div class="relative z-10">
:style="{ <div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
backdropFilter: 'blur(6px)', <h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">{{ metric.value }}</h2>
}" <h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
> </div>
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2"> <h3 class="flex items-center gap-2 text-base font-normal text-secondary">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast"> {{ metric.title }}
{{ metric.value }} <WarningIcon
</h2> v-if="metric.warning"
<h3 class="relative z-10 text-sm font-normal text-secondary">/ {{ metric.max }}</h3> v-tooltip="metric.warning"
class="size-5"
:style="{ color: 'var(--color-orange)' }"
/>
</h3>
</div> </div>
<h3 class="relative z-10 flex items-center gap-2 text-base font-normal text-secondary"> <div class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
{{ metric.title }}
<WarningIcon
v-tooltip="getPotentialWarning(metric)"
:style="{
color: 'var(--color-orange)',
width: '1.25rem',
height: '1.25rem',
display: getPotentialWarning(metric) ? 'block' : 'none',
}"
/>
</h3>
</div> </div>
<component :is="metric.icon" class="absolute right-10 top-10 z-10" /> <component :is="metric.icon" class="absolute right-10 top-10 z-10" />
<ClientOnly> <ClientOnly>
<VueApexCharts <VueApexCharts
v-if=" v-if="metric.showGraph"
metric.data.length && !(metric.title === 'Memory usage' && userPreferences.ramAsNumber)
"
ref="chart"
type="area" type="area"
height="142" height="142"
:options="generateOptions(metric)" :options="getChartOptions(metric.warning)"
:series="[{ name: 'Chart', data: metric.data }]" :series="[{ name: metric.title, data: metric.data }]"
class="chart chart-animation absolute bottom-0 left-0 right-0 w-full" class="chart absolute bottom-0 left-0 right-0 w-full opacity-0"
/> />
</ClientOnly> </ClientOnly>
</div> </div>
@@ -57,21 +47,17 @@
> >
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast"> <h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
{{ formatBytes(animatedStorageUsage) }} {{ formatBytes(stats.storage_usage_bytes) }}
</h2> </h2>
<!-- <h3 class="relative z-10 text-sm font-normal text-secondary">
/ {{ formatBytes(props.data.current.storage_total_bytes) }}
</h3> -->
</div> </div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3> <h3 class="text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" /> <FolderOpenIcon class="absolute right-10 top-10 size-8" />
</NuxtLink> </NuxtLink>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from "vue"; import { ref, computed, shallowRef } from "vue";
import { FolderOpenIcon, CPUIcon, DBIcon } from "@modrinth/assets"; import { FolderOpenIcon, CPUIcon, DBIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core"; import { useStorage } from "@vueuse/core";
import type { Stats } from "~/types/servers"; import type { Stats } from "~/types/servers";
@@ -79,252 +65,132 @@ import WarningIcon from "~/assets/images/utils/issues.svg?component";
const route = useNativeRoute(); const route = useNativeRoute();
const serverId = route.params.id; const serverId = route.params.id;
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, { const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
ramAsNumber: false, ramAsNumber: false,
autoRestart: false,
backupWhileRunning: false,
}); });
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts")); const props = defineProps<{ data: Stats }>();
const props = defineProps({ const stats = shallowRef(props.data.current);
data: {
type: Object as PropType<Stats>,
required: true,
},
});
const lerp = (a: number, b: number) => {
return a + (b - a) * 0.5;
};
// I told you it would go into prod
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
const units = ["Bytes", "KB", "MB", "GB", "TB"]; const units = ["B", "KB", "MB", "GB"];
let value = bytes; let value = bytes;
let unitIndex = 0; let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
while (value >= 1024 && unitIndex < units.length - 2) {
value /= 1024; value /= 1024;
unitIndex++; unit++;
} }
return `${Math.round(value * 10) / 10} ${units[unit]}`;
return `${Math.round(value * 100) / 100} ${units[unitIndex]}`;
}; };
const animatedStorageUsage = ref(0); const cpuData = ref<number[]>(Array(20).fill(0));
const ramData = ref<number[]>(Array(20).fill(0));
const animateValue = (start: number, end: number, duration: number): void => { const updateGraphData = (arr: number[], newValue: number) => {
let startTimestamp: number | null = null; arr.push(newValue);
const step = (timestamp: number) => { arr.shift();
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
animatedStorageUsage.value = Math.floor(progress * (end - start) + start);
if (progress < 1) {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}; };
onMounted(() => { const metrics = computed(() => {
animateValue(0, props.data.current.storage_usage_bytes, 250); const ramPercent = Math.min(
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
100,
);
const cpuPercent = Math.min(stats.value.cpu_percent, 100);
updateGraphData(cpuData.value, cpuPercent);
updateGraphData(ramData.value, ramPercent);
return [
{
title: "CPU usage",
value: `${cpuPercent.toFixed(2)}%`,
max: "100%",
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%",
icon: DBIcon,
data: ramData.value,
showGraph: true,
warning: ramPercent >= 90 ? "Memory usage is very high" : null,
},
];
});
const getChartOptions = (hasWarning: string | null) => ({
chart: {
type: "area",
animations: { enabled: false },
sparkline: { enabled: true },
toolbar: { show: false },
padding: {
left: -10,
right: -10,
top: 0,
bottom: 0,
},
},
stroke: { curve: "smooth", width: 3 },
fill: {
type: "gradient",
gradient: {
shadeIntensity: 1,
opacityFrom: 0.25,
opacityTo: 0.05,
stops: [0, 100],
},
},
tooltip: { enabled: false },
grid: { show: false },
xaxis: {
labels: { show: false },
axisBorder: { show: false },
type: "numeric",
tickAmount: 20,
range: 20,
},
yaxis: {
show: false,
min: 0,
max: 100,
forceNiceScale: false,
},
colors: [hasWarning ? "var(--color-orange)" : "var(--color-brand)"],
dataLabels: {
enabled: false,
},
}); });
watch( watch(
() => props.data.current.storage_usage_bytes, () => props.data.current,
(newValue, oldValue) => { (newStats) => {
animateValue(oldValue, newValue, 250); stats.value = newStats;
}, },
); );
const metrics = ref([
{
title: "CPU usage",
value: "0%",
max: "100%",
icon: markRaw(CPUIcon),
data: [] as number[],
},
{
title: "Memory usage",
value: "0%",
max: userPreferences.value.ramAsNumber
? formatBytes(props.data.current.ram_total_bytes)
: "100%",
icon: markRaw(DBIcon),
data: [] as number[],
},
]);
const updateMetrics = () => {
console.log(props.data.current.ram_usage_bytes);
metrics.value = metrics.value.map((metric, index) => {
if (userPreferences.value.ramAsNumber && index === 1) {
return {
...metric,
value: formatBytes(props.data.current.ram_usage_bytes),
data: [...metric.data.slice(-10), props.data.current.ram_usage_bytes],
max: formatBytes(props.data.current.ram_total_bytes),
};
} else {
const currentValue =
index === 0
? props.data.current.cpu_percent
: Math.min(
(props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100,
100,
);
const pastValue =
index === 0
? props.data.past.cpu_percent
: Math.min(
(props.data.past.ram_usage_bytes / props.data.past.ram_total_bytes) * 100,
100,
);
const newValue = lerp(currentValue, pastValue);
return {
...metric,
value: `${newValue.toFixed(2)}%`,
data: [...metric.data.slice(-10), newValue],
// data: [36, 36],
};
}
});
};
// aww, you gotta give em that rinth tuah, mod on that thang
const getPotentialWarning = (metric: (typeof metrics.value)[0]) => {
// make all words in the string lowercase, unless the word is in all caps
const split = metric.title.split(" ");
const title = split
.map((word) => {
if (word === word.toUpperCase()) {
return word;
}
return word.toLowerCase();
})
.join(" ");
let data = metric.data.at(-1) || 0;
if (userPreferences.value.ramAsNumber) {
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
}
switch (true) {
case data >= 90:
return `Your server's ${title} is very high.`;
default:
return "";
}
};
const generateOptions = (metric: (typeof metrics.value)[0]) => {
let color = "var(--color-brand)";
let data = metric.data.at(-1) || 0;
if (userPreferences.value.ramAsNumber) {
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
}
switch (true) {
case data >= 90:
color = "var(--color-red)";
break;
case data >= 80:
color = "var(--color-orange)";
break;
}
return {
chart: {
id: "stats",
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
foreColor: "var(--color-base)",
toolbar: { show: false },
zoom: { enabled: false },
sparkline: { enabled: true },
animations: {
enabled: true,
easing: "linear",
dynamicAnimation: { speed: 1000 },
},
},
stroke: { curve: "smooth" },
fill: {
colors: [color],
type: "gradient",
opacity: 1,
gradient: {
shade: "light",
type: "vertical",
shadeIntensity: 0,
gradientToColors: [color],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: { show: false },
legend: { show: false },
colors: [color],
dataLabels: { enabled: false },
xaxis: {
type: "numeric",
lines: { show: false },
axisBorder: { show: false },
labels: { show: false },
},
yaxis: {
min: 0,
max: 100,
tickAmount: 5,
labels: { show: false },
axisBorder: { show: false },
axisTicks: { show: false },
},
tooltip: { enabled: false },
};
};
// watch(
// metrics,
// () => {
// console.log(metrics.value[0].data.at(-1));
// },
// {
// deep: true,
// immediate: true,
// },
// );
let interval: number;
onMounted(() => {
updateMetrics();
interval = window.setInterval(updateMetrics, 1000);
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
});
</script> </script>
<style scoped> <style scoped>
@keyframes chart-enter-animation { .chart {
0% { animation: fadeIn 0.2s ease-out 0.2s forwards;
opacity: 0; margin-left: -24px;
} margin-right: -24px;
100% { width: calc(100% + 48px) !important;
}
@keyframes fadeIn {
to {
opacity: 1; opacity: 1;
} }
} }
.chart-animation {
opacity: 0;
animation: chart-enter-animation 0.5s ease-out forwards;
animation-delay: 1s;
}
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
v-if="subdomain" v-if="subdomain && !isHidden"
v-tooltip="'Copy custom URL'" v-tooltip="'Copy custom URL'"
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer" class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
> >
@@ -20,6 +20,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { LinkIcon } from "@modrinth/assets"; import { LinkIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core";
const props = defineProps<{ const props = defineProps<{
subdomain: string; subdomain: string;
noSeparator?: boolean; noSeparator?: boolean;
@@ -29,12 +31,18 @@ const copySubdomain = () => {
navigator.clipboard.writeText(props.subdomain + ".modrinth.gg"); navigator.clipboard.writeText(props.subdomain + ".modrinth.gg");
addNotification({ addNotification({
group: "servers", group: "servers",
title: "Subdomain copied", title: "Custom URL copied",
text: "Your subdomain has been copied to your clipboard.", text: "Your server's URL has been copied to your clipboard.",
type: "success", type: "success",
}); });
}; };
const route = useNativeRoute(); const route = useNativeRoute();
const serverId = computed(() => route.params.id as string); const serverId = computed(() => route.params.id as string);
const userPreferences = useStorage(`pyro-server-${serverId.value}-preferences`, {
hideSubdomainLabel: false,
});
const isHidden = computed(() => userPreferences.value.hideSubdomainLabel);
</script> </script>

View File

@@ -2,13 +2,13 @@
<div <div
v-if="uptimeSeconds || uptimeSeconds !== 0" v-if="uptimeSeconds || uptimeSeconds !== 0"
v-tooltip="`Online for ${verboseUptime}`" v-tooltip="`Online for ${verboseUptime}`"
class="flex min-w-0 flex-row items-center gap-4" class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-4"
data-pyro-uptime data-pyro-uptime
> >
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div> <div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div class="flex gap-2"> <div class="flex gap-2">
<UiServersTimer class="flex size-5 shrink-0" /> <UiServersIconsTimer class="flex size-5 shrink-0" />
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime"> <time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
{{ formattedUptime }} {{ formattedUptime }}
</time> </time>

View File

@@ -1,28 +1,23 @@
<template> <template>
<div <div class="relative inline-block h-9 w-full max-w-80">
ref="dropdown" <button
data-pyro-dropdown ref="triggerRef"
tabindex="0" type="button"
role="combobox" aria-haspopup="listbox"
:aria-expanded="dropdownVisible" :aria-expanded="dropdownVisible"
class="relative inline-block h-9 w-full max-w-80" :aria-controls="listboxId"
@focus="onFocus" :aria-labelledby="listboxId"
@blur="onBlur" class="duration-50 flex h-full w-full cursor-pointer select-none appearance-none items-center justify-between gap-4 rounded-xl border-none bg-button-bg px-4 py-2 shadow-sm !outline-none transition-all ease-in-out"
@mousedown.prevent
@keydown="handleKeyDown"
>
<div
data-pyro-dropdown-trigger
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
:class="triggerClasses" :class="triggerClasses"
@click="toggleDropdown" @click="toggleDropdown"
@keydown="handleTriggerKeyDown"
> >
<span>{{ selectedOption }}</span> <span>{{ selectedOption }}</span>
<DropdownIcon <DropdownIcon
class="transition-transform duration-200 ease-in-out" class="transition-transform duration-200 ease-in-out"
:class="{ 'rotate-180': dropdownVisible }" :class="{ 'rotate-180': dropdownVisible }"
/> />
</div> </button>
<Teleport to="#teleports"> <Teleport to="#teleports">
<transition <transition
@@ -35,27 +30,28 @@
> >
<div <div
v-if="dropdownVisible" v-if="dropdownVisible"
:id="listboxId"
ref="optionsContainer" ref="optionsContainer"
data-pyro-dropdown-options role="listbox"
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg" tabindex="-1"
:aria-activedescendant="activeDescendant"
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg outline-none"
:class="{ :class="{
'rounded-b-xl': !isRenderingUp, 'rounded-b-xl': !isRenderingUp,
'rounded-t-xl': isRenderingUp, 'rounded-t-xl': isRenderingUp,
}" }"
:style="positionStyle" :style="positionStyle"
@keydown.stop="handleDropdownKeyDown" @keydown="handleListboxKeyDown"
> >
<div <div
class="overflow-y-auto" class="overflow-y-auto"
:style="{ height: `${virtualListHeight}px` }" :style="{ height: `${virtualListHeight}px` }"
data-pyro-dropdown-options-virtual-scroller
@scroll="handleScroll" @scroll="handleScroll"
> >
<div :style="{ height: `${totalHeight}px`, position: 'relative' }"> <div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div <div
v-for="item in visibleOptions" v-for="item in visibleOptions"
:key="item.index" :key="item.index"
data-pyro-dropdown-option
:style="{ :style="{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
@@ -65,32 +61,20 @@
}" }"
> >
<div <div
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)" :id="`${listboxId}-option-${item.index}`"
role="option" role="option"
:tabindex="focusedOptionIndex === item.index ? 0 : -1" :aria-selected="selectedValue === item.option"
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none" class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out"
:class="{ :class="{
'bg-brand font-bold text-brand-inverted': selectedValue === item.option, 'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
'bg-bg-raised': focusedOptionIndex === item.index, 'bg-bg-raised': focusedOptionIndex === item.index,
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp, 'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
'rounded-t-xl': item.index === 0 && isRenderingUp, 'rounded-t-xl': item.index === 0 && isRenderingUp,
}" }"
:aria-selected="selectedValue === item.option"
@click="selectOption(item.option, item.index)" @click="selectOption(item.option, item.index)"
@mouseover="focusedOptionIndex = item.index" @mousemove="focusedOptionIndex = item.index"
@focus="focusedOptionIndex = item.index"
> >
<input {{ displayName(item.option) }}
:id="`${name}-${item.index}`"
v-model="radioValue"
type="radio"
:value="item.option"
:name="name"
class="hidden"
/>
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
{{ displayName(item.option) }}
</label>
</div> </div>
</div> </div>
</div> </div>
@@ -140,13 +124,14 @@ const emit = defineEmits<{
const dropdownVisible = ref(false); const dropdownVisible = ref(false);
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue); const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue);
const focusedOptionIndex = ref<number | null>(null); const focusedOptionIndex = ref<number | null>(null);
const focusedOptionRef = ref<HTMLElement | null>(null);
const dropdown = ref<HTMLElement | null>(null);
const optionsContainer = ref<HTMLElement | null>(null); const optionsContainer = ref<HTMLElement | null>(null);
const scrollTop = ref(0); const scrollTop = ref(0);
const isRenderingUp = ref(false); const isRenderingUp = ref(false);
const virtualListHeight = ref(300); const virtualListHeight = ref(300);
const lastFocusedElement = ref<HTMLElement | null>(null); const isOpen = ref(false);
const openDropdownCount = ref(0);
const listboxId = `pyro-listbox-${Math.random().toString(36).substring(2, 11)}`;
const triggerRef = ref<HTMLButtonElement | null>(null);
const positionStyle = ref<CSSProperties>({ const positionStyle = ref<CSSProperties>({
position: "fixed", position: "fixed",
@@ -156,41 +141,6 @@ const positionStyle = ref<CSSProperties>({
zIndex: 999, zIndex: 999,
}); });
const handleOptionRef = (el: HTMLElement | null, index: number) => {
if (focusedOptionIndex.value === index) {
focusedOptionRef.value = el;
}
};
const onFocus = async () => {
if (!props.disabled) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
lastFocusedElement.value = document.activeElement as HTMLElement;
dropdownVisible.value = true;
await updatePosition();
nextTick(() => {
dropdown.value?.focus();
});
}
};
const onBlur = (event: FocusEvent) => {
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
closeDropdown();
}
};
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element;
while (currentNode) {
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
return true;
}
currentNode = currentNode.parentElement;
}
return false;
};
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT); const totalHeight = computed(() => props.options.length * ITEM_HEIGHT);
const visibleOptions = computed(() => { const visibleOptions = computed(() => {
@@ -227,16 +177,16 @@ const radioValue = computed<OptionValue>({
}); });
const triggerClasses = computed(() => ({ const triggerClasses = computed(() => ({
"cursor-not-allowed opacity-50 grayscale": props.disabled, "!cursor-not-allowed opacity-50 grayscale": props.disabled,
"rounded-b-none": dropdownVisible.value && !isRenderingUp.value && !props.disabled, "rounded-b-none": dropdownVisible.value && !isRenderingUp.value && !props.disabled,
"rounded-t-none": dropdownVisible.value && isRenderingUp.value && !props.disabled, "rounded-t-none": dropdownVisible.value && isRenderingUp.value && !props.disabled,
})); }));
const updatePosition = async () => { const updatePosition = async () => {
if (!dropdown.value) return; if (!triggerRef.value) return;
await nextTick(); await nextTick();
const triggerRect = dropdown.value.getBoundingClientRect(); const triggerRect = triggerRef.value.getBoundingClientRect();
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
const margin = 8; const margin = 8;
@@ -263,20 +213,6 @@ const updatePosition = async () => {
}; };
}; };
const openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns();
dropdownVisible.value = true;
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
lastFocusedElement.value = document.activeElement as HTMLElement;
await updatePosition();
requestAnimationFrame(() => {
updatePosition();
});
}
};
const toggleDropdown = () => { const toggleDropdown = () => {
if (!props.disabled) { if (!props.disabled) {
if (dropdownVisible.value) { if (dropdownVisible.value) {
@@ -300,61 +236,6 @@ const handleScroll = (event: Event) => {
scrollTop.value = target.scrollTop; scrollTop.value = target.scrollTop;
}; };
const handleKeyDown = (event: KeyboardEvent) => {
if (!dropdownVisible.value) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
lastFocusedElement.value = document.activeElement as HTMLElement;
toggleDropdown();
}
} else {
handleDropdownKeyDown(event);
}
};
const handleDropdownKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusNextOption();
break;
case "ArrowUp":
event.preventDefault();
focusPreviousOption();
break;
case "Enter":
event.preventDefault();
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
}
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
closeDropdown();
break;
case "Tab":
event.preventDefault();
if (event.shiftKey) {
focusPreviousOption();
} else {
focusNextOption();
}
break;
}
};
const closeDropdown = () => {
dropdownVisible.value = false;
focusedOptionIndex.value = null;
if (lastFocusedElement.value) {
lastFocusedElement.value.focus();
lastFocusedElement.value = null;
}
};
const closeAllDropdowns = () => { const closeAllDropdowns = () => {
const event = new CustomEvent("close-all-dropdowns"); const event = new CustomEvent("close-all-dropdowns");
window.dispatchEvent(event); window.dispatchEvent(event);
@@ -373,9 +254,6 @@ const focusNextOption = () => {
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length; focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length;
} }
scrollToFocused(); scrollToFocused();
nextTick(() => {
focusedOptionRef.value?.focus();
});
}; };
const focusPreviousOption = () => { const focusPreviousOption = () => {
@@ -386,9 +264,6 @@ const focusPreviousOption = () => {
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length; (focusedOptionIndex.value - 1 + props.options.length) % props.options.length;
} }
scrollToFocused(); scrollToFocused();
nextTick(() => {
focusedOptionRef.value?.focus();
});
}; };
const scrollToFocused = () => { const scrollToFocused = () => {
@@ -407,6 +282,119 @@ const scrollToFocused = () => {
} }
}; };
const openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns();
dropdownVisible.value = true;
isOpen.value = true;
openDropdownCount.value++;
document.body.style.overflow = "hidden";
await updatePosition();
nextTick(() => {
optionsContainer.value?.focus();
});
}
};
const closeDropdown = () => {
if (isOpen.value) {
dropdownVisible.value = false;
isOpen.value = false;
openDropdownCount.value--;
if (openDropdownCount.value === 0) {
document.body.style.overflow = "";
}
focusedOptionIndex.value = null;
triggerRef.value?.focus();
}
};
const handleTriggerKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
case "ArrowUp":
event.preventDefault();
if (!dropdownVisible.value) {
openDropdown();
focusedOptionIndex.value = event.key === "ArrowUp" ? props.options.length - 1 : 0;
} else if (event.key === "ArrowDown") {
focusNextOption();
} else {
focusPreviousOption();
}
break;
case "Enter":
case " ":
event.preventDefault();
if (!dropdownVisible.value) {
openDropdown();
focusedOptionIndex.value = 0;
} else if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
}
break;
case "Escape":
event.preventDefault();
closeDropdown();
break;
case "Tab":
if (dropdownVisible.value) {
event.preventDefault();
}
break;
}
};
const handleListboxKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "Enter":
case " ":
event.preventDefault();
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
}
break;
case "ArrowDown":
event.preventDefault();
focusNextOption();
break;
case "ArrowUp":
event.preventDefault();
focusPreviousOption();
break;
case "Escape":
event.preventDefault();
closeDropdown();
break;
case "Tab":
event.preventDefault();
break;
case "Home":
event.preventDefault();
focusedOptionIndex.value = 0;
scrollToFocused();
break;
case "End":
event.preventDefault();
focusedOptionIndex.value = props.options.length - 1;
scrollToFocused();
break;
default:
if (event.key.length === 1) {
const char = event.key.toLowerCase();
const index = props.options.findIndex((option) =>
props.displayName(option).toLowerCase().startsWith(char),
);
if (index !== -1) {
focusedOptionIndex.value = index;
scrollToFocused();
}
}
break;
}
};
onMounted(() => { onMounted(() => {
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleResize, true); window.addEventListener("scroll", handleResize, true);
@@ -416,6 +404,10 @@ onMounted(() => {
} }
}); });
window.addEventListener("close-all-dropdowns", closeDropdown); window.addEventListener("close-all-dropdowns", closeDropdown);
if (selectedValue.value) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
}
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -427,7 +419,13 @@ onUnmounted(() => {
} }
}); });
window.removeEventListener("close-all-dropdowns", closeDropdown); window.removeEventListener("close-all-dropdowns", closeDropdown);
lastFocusedElement.value = null;
if (isOpen.value) {
openDropdownCount.value--;
if (openDropdownCount.value === 0) {
document.body.style.overflow = "";
}
}
}); });
watch( watch(
@@ -443,4 +441,19 @@ watch(dropdownVisible, async (newValue) => {
scrollTop.value = 0; scrollTop.value = 0;
} }
}); });
const activeDescendant = computed(() =>
focusedOptionIndex.value !== null ? `${listboxId}-option-${focusedOptionIndex.value}` : undefined,
);
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element;
while (currentNode) {
if (currentNode === triggerRef.value || currentNode === optionsContainer.value) {
return true;
}
currentNode = currentNode.parentElement;
}
return false;
};
</script> </script>

View File

@@ -0,0 +1,16 @@
<template>
<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="lucide lucide-chevron-down"
>
<path d="m6 9 6 6 6-6" />
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<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="lucide lucide-chevron-up"
>
<path d="m18 15-6-6-6 6" />
</svg>
</template>

View File

@@ -21,6 +21,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
developerMode: false, developerMode: false,
showVersionFilesInTable: false, showVersionFilesInTable: false,
showAdsWithPlus: false, showAdsWithPlus: false,
alwaysShowChecklistAsPopup: true,
// Feature toggles // Feature toggles
projectTypesPrimaryNav: false, projectTypesPrimaryNav: false,

View File

@@ -10,19 +10,111 @@ interface PyroFetchOptions {
url?: string; url?: string;
token?: string; token?: string;
}; };
retry?: boolean; retry?: number | boolean;
} }
async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> { class PyroServerError extends Error {
public readonly errors: Map<string, Error> = new Map();
public readonly timestamp: number = Date.now();
constructor(message?: string) {
super(message || "Multiple errors occurred");
this.name = "PyroServerError";
}
addError(module: string, error: Error) {
this.errors.set(module, error);
this.message = this.buildErrorMessage();
}
hasErrors() {
return this.errors.size > 0;
}
private buildErrorMessage(): string {
return Array.from(this.errors.entries())
.map(([_module, error]) => error.message)
.join("\n");
}
}
export class PyroServersFetchError extends Error {
constructor(
message: string,
public readonly statusCode?: number,
public readonly originalError?: Error,
public readonly module?: string,
) {
let errorMessage = message;
let method = "GET";
let path = "";
if (originalError instanceof FetchError) {
const matches = message.match(/\[([A-Z]+)\]\s+"([^"]+)":/);
if (matches) {
method = matches[1];
path = matches[2].replace(/https?:\/\/[^/]+\/[^/]+\/v\d+\//, "");
}
const statusMessage = (() => {
if (!statusCode) return "Unknown Error";
switch (statusCode) {
case 400:
return "Bad Request";
case 401:
return "Unauthorized";
case 403:
return "Forbidden";
case 404:
return "Not Found";
case 408:
return "Request Timeout";
case 429:
return "Too Many Requests";
case 500:
return "Internal Server Error";
case 502:
return "Bad Gateway";
case 503:
return "Service Unavailable";
case 504:
return "Gateway Timeout";
default:
return `HTTP ${statusCode}`;
}
})();
errorMessage = `[${method}] ${statusMessage} (${statusCode}) while fetching ${path}${module ? ` in ${module}` : ""}`;
} else {
errorMessage = `${message}${statusCode ? ` (${statusCode})` : ""}${module ? ` in ${module}` : ""}`;
}
super(errorMessage);
this.name = "PyroServersFetchError";
}
}
async function PyroFetch<T>(
path: string,
options: PyroFetchOptions = {},
module?: string,
): Promise<T> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const auth = await useAuth(); const auth = await useAuth();
const authToken = auth.value?.token; const authToken = auth.value?.token;
if (!authToken) { if (!authToken) {
throw new PyroFetchError("Cannot pyrofetch without auth", 10000); throw new PyroServersFetchError("Missing auth token", 401, undefined, module);
} }
const { method = "GET", contentType = "application/json", body, version = 0, override } = options; const {
method = "GET",
contentType = "application/json",
body,
version = 0,
override,
retry = method === "GET" ? 3 : 0,
} = options;
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace( const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/, /\/$/,
@@ -30,9 +122,11 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
); );
if (!base) { if (!base) {
throw new PyroFetchError( throw new PyroServersFetchError(
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables", "Configuration error: Missing PYRO_BASE_URL",
10001, 500,
undefined,
module,
); );
} }
@@ -40,9 +134,7 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
? `https://${override.url}/${path.replace(/^\//, "")}` ? `https://${override.url}/${path.replace(/^\//, "")}`
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`; : `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
type HeadersRecord = Record<string, string>; const headers: Record<string, string> = {
const headers: HeadersRecord = {
Authorization: `Bearer ${override?.token ?? authToken}`, Authorization: `Bearer ${override?.token ?? authToken}`,
"Access-Control-Allow-Headers": "Authorization", "Access-Control-Allow-Headers": "Authorization",
"User-Agent": "Pyro/1.0 (https://pyro.host)", "User-Agent": "Pyro/1.0 (https://pyro.host)",
@@ -57,43 +149,47 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
headers.Origin = window.location.origin; headers.Origin = window.location.origin;
} }
try { let attempts = 0;
const response = await $fetch<T>(fullUrl, { const maxAttempts = (typeof retry === "boolean" ? (retry ? 1 : 0) : retry) + 1;
method, let lastError: Error | null = null;
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined, while (attempts < maxAttempts) {
timeout: 10000, try {
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0, const response = await $fetch<T>(fullUrl, {
}); method,
return response; headers,
} catch (error) { body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
console.error("[PyroServers/PyroFetch]:", error); timeout: 10000,
if (error instanceof FetchError) { });
const statusCode = error.response?.status;
const statusText = error.response?.statusText || "[no status text available]"; return response;
const errorMessages: { [key: number]: string } = { } catch (error) {
400: "Bad Request", lastError = error as Error;
401: "Unauthorized", attempts++;
403: "Forbidden",
404: "Not Found", if (error instanceof FetchError) {
405: "Method Not Allowed", const statusCode = error.response?.status;
429: "Too Many Requests", const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
500: "Internal Server Error",
502: "Bad Gateway", if (!isRetryable || attempts >= maxAttempts) {
503: "Service Unavailable", throw new PyroServersFetchError(error.message, statusCode, error, module);
}; }
const message =
statusCode && statusCode in errorMessages const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
? errorMessages[statusCode] await new Promise((resolve) => setTimeout(resolve, delay));
: `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`; continue;
throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error); }
throw new PyroServersFetchError(
"Unexpected error during fetch operation",
undefined,
error as Error,
module,
);
} }
throw new PyroFetchError(
"[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.",
undefined,
error as Error,
);
} }
throw lastError || new Error("Maximum retry attempts reached");
} }
const internalServerRefrence = ref<any>(null); const internalServerRefrence = ref<any>(null);
@@ -252,7 +348,7 @@ export interface DirectoryResponse {
current?: number; current?: number;
} }
type ContentType = "Mod" | "Plugin"; type ContentType = "mod" | "plugin";
const constructServerProperties = (properties: any): string => { const constructServerProperties = (properties: any): string => {
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`; let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
@@ -271,95 +367,96 @@ const constructServerProperties = (properties: any): string => {
}; };
const processImage = async (iconUrl: string | undefined) => { const processImage = async (iconUrl: string | undefined) => {
const image = ref<string | null>(null); const sharedImage = useState<string | undefined>(
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`); `server-icon-${internalServerRefrence.value.serverId}`,
try { );
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
override: auth,
retry: false,
});
if (fileData instanceof Blob) { if (sharedImage.value) {
if (import.meta.client) { return sharedImage.value;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(fileData);
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
internalServerRefrence.value.general.image = dataURL;
image.value = dataURL;
resolve();
};
});
}
}
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) {
console.log("[PYROSERVERS] No server icon found");
} else {
console.error(error);
}
} }
if (image.value === null && iconUrl) { try {
console.log("iconUrl", iconUrl); const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
try { try {
const response = await fetch(iconUrl); const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
const file = await response.blob(); override: auth,
const originalfile = new File([file], "server-icon-original.png", { retry: false,
type: "image/png",
}); });
if (import.meta.client) {
const scaledFile = await new Promise<File>((resolve, reject) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob((blob) => {
if (blob) {
const data = new File([blob], "server-icon.png", { type: "image/png" });
resolve(data);
} else {
reject(new Error("Canvas toBlob failed"));
}
}, "image/png");
};
img.onerror = reject;
});
if (scaledFile) {
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, { if (fileData instanceof Blob) {
method: "POST", if (import.meta.client) {
contentType: "application/octet-stream", const dataURL = await new Promise<string>((resolve) => {
body: originalfile, const canvas = document.createElement("canvas");
override: auth, const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(fileData);
}); });
return dataURL;
} }
} }
} catch (error) { } catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) { if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
console.log("[PYROSERVERS] No server icon found"); try {
} else { const response = await fetch(iconUrl);
console.error(error); if (!response.ok) throw new Error("Failed to fetch icon");
const file = await response.blob();
const originalFile = new File([file], "server-icon-original.png", { type: "image/png" });
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" });
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalFile,
override: auth,
});
}
}, "image/png");
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
return dataURL;
}
} catch (error) {
console.error("Failed to process external icon:", error);
}
} }
} }
} catch (error) {
console.error("Failed to process server icon:", error);
} }
return image.value;
sharedImage.value = undefined;
return undefined;
}; };
// ------------------ GENERAL ------------------ // // ------------------ GENERAL ------------------ //
@@ -519,8 +616,8 @@ const installContent = async (contentType: ContentType, projectId: string, versi
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, { await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, {
method: "POST", method: "POST",
body: { body: {
install_as: contentType,
rinth_ids: { project_id: projectId, version_id: versionId }, rinth_ids: { project_id: projectId, version_id: versionId },
install_as: contentType,
}, },
}); });
} catch (error) { } catch (error) {
@@ -529,13 +626,12 @@ const installContent = async (contentType: ContentType, projectId: string, versi
} }
}; };
const removeContent = async (contentType: ContentType, contentId: string) => { const removeContent = async (path: string) => {
try { try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, { await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, {
method: "POST", method: "POST",
body: { body: {
install_as: contentType, path,
path: contentId,
}, },
}); });
} catch (error) { } catch (error) {
@@ -544,15 +640,11 @@ const removeContent = async (contentType: ContentType, contentId: string) => {
} }
}; };
const reinstallContent = async ( const reinstallContent = async (replace: string, projectId: string, versionId: string) => {
contentType: ContentType,
contentId: string,
newContentId: string,
) => {
try { try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/${contentId}`, { await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/update`, {
method: "PUT", method: "POST",
body: { install_as: contentType, version_id: newContentId }, body: { replace, project_id: projectId, version_id: versionId },
}); });
} catch (error) { } catch (error) {
console.error("Error reinstalling mod:", error); console.error("Error reinstalling mod:", error);
@@ -564,10 +656,14 @@ const reinstallContent = async (
const createBackup = async (backupName: string) => { const createBackup = async (backupName: string) => {
try { try {
const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, { const response = await PyroFetch<{ id: string }>(
method: "POST", `servers/${internalServerRefrence.value.serverId}/backups`,
body: { name: backupName }, {
})) as { id: string }; method: "POST",
body: { name: backupName },
},
);
await internalServerRefrence.value.refresh(["backups"]);
return response.id; return response.id;
} catch (error) { } catch (error) {
console.error("Error creating backup:", error); console.error("Error creating backup:", error);
@@ -581,6 +677,7 @@ const renameBackup = async (backupId: string, newName: string) => {
method: "POST", method: "POST",
body: { name: newName }, body: { name: newName },
}); });
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) { } catch (error) {
console.error("Error renaming backup:", error); console.error("Error renaming backup:", error);
throw error; throw error;
@@ -592,6 +689,7 @@ const deleteBackup = async (backupId: string) => {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, { await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
method: "DELETE", method: "DELETE",
}); });
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) { } catch (error) {
console.error("Error deleting backup:", error); console.error("Error deleting backup:", error);
throw error; throw error;
@@ -606,6 +704,7 @@ const restoreBackup = async (backupId: string) => {
method: "POST", method: "POST",
}, },
); );
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) { } catch (error) {
console.error("Error restoring backup:", error); console.error("Error restoring backup:", error);
throw error; throw error;
@@ -644,12 +743,10 @@ const getAutoBackup = async () => {
const lockBackup = async (backupId: string) => { const lockBackup = async (backupId: string) => {
try { try {
return await PyroFetch( await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, method: "POST",
{ });
method: "POST", await internalServerRefrence.value.refresh(["backups"]);
},
);
} catch (error) { } catch (error) {
console.error("Error locking backup:", error); console.error("Error locking backup:", error);
throw error; throw error;
@@ -658,14 +755,12 @@ const lockBackup = async (backupId: string) => {
const unlockBackup = async (backupId: string) => { const unlockBackup = async (backupId: string) => {
try { try {
return await PyroFetch( await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, method: "POST",
{ });
method: "POST", await internalServerRefrence.value.refresh(["backups"]);
},
);
} catch (error) { } catch (error) {
console.error("Error locking backup:", error); console.error("Error unlocking backup:", error);
throw error; throw error;
} }
}; };
@@ -760,7 +855,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
try { try {
return await requestFn(); return await requestFn();
} catch (error) { } catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 401) { if (error instanceof PyroServersFetchError && error.statusCode === 401) {
await internalServerRefrence.value.refresh(["fs"]); await internalServerRefrence.value.refresh(["fs"]);
return await requestFn(); return await requestFn();
} }
@@ -947,17 +1042,18 @@ const modules: any = {
general: { general: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
const data = await PyroFetch<General>(`servers/${serverId}`); const data = await PyroFetch<General>(`servers/${serverId}`, {}, "general");
// TODO: temp hack to fix hydration error
if (data.upstream?.project_id) { if (data.upstream?.project_id) {
const res = await $fetch( const res = await $fetch(
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`, `https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
); );
data.project = res as Project; data.project = res as Project;
} }
if (import.meta.client) { if (import.meta.client) {
data.image = (await processImage(data.project?.icon_url)) ?? undefined; data.image = (await processImage(data.project?.icon_url)) ?? undefined;
} }
const motd = await getMotd(); const motd = await getMotd();
if (motd === "A Minecraft Server") { if (motd === "A Minecraft Server") {
await setMotd( await setMotd(
@@ -967,8 +1063,19 @@ const modules: any = {
data.motd = motd; data.motd = motd;
return data; return data;
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
status: "error",
server_id: serverId,
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
updateName, updateName,
@@ -978,21 +1085,27 @@ const modules: any = {
suspend: suspendServer, suspend: suspendServer,
getMotd, getMotd,
setMotd, setMotd,
fetchConfigFile,
}, },
content: { content: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`); const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`, {}, "content");
return { return {
data: data: mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")),
internalServerRefrence.value.error === undefined
? mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? ""))
: [],
}; };
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
data: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
install: installContent, install: installContent,
@@ -1002,10 +1115,22 @@ const modules: any = {
backups: { backups: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return { data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`) }; return {
data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`, {}, "backups"),
};
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
data: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
create: createBackup, create: createBackup,
@@ -1021,10 +1146,26 @@ const modules: any = {
network: { network: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return { allocations: await PyroFetch<Allocation[]>(`servers/${serverId}/allocations`) }; return {
allocations: await PyroFetch<Allocation[]>(
`servers/${serverId}/allocations`,
{},
"network",
),
};
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
allocations: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
reserveAllocation, reserveAllocation,
@@ -1036,10 +1177,19 @@ const modules: any = {
startup: { startup: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return await PyroFetch<Startup>(`servers/${serverId}/startup`); return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
update: updateStartupSettings, update: updateStartupSettings,
@@ -1047,20 +1197,39 @@ const modules: any = {
ws: { ws: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`); return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
}, },
fs: { fs: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`) }; return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
auth: undefined,
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
listDirContents, listDirContents,
@@ -1136,8 +1305,7 @@ type GeneralFunctions = {
setMotd: (motd: string) => Promise<void>; setMotd: (motd: string) => Promise<void>;
/** /**
* INTERNAL: Gets the config file of a server. * @deprecated Use fs.downloadFile instead
* @param fileName - The name of the file.
*/ */
fetchConfigFile: (fileName: string) => Promise<any>; fetchConfigFile: (fileName: string) => Promise<any>;
}; };
@@ -1160,18 +1328,17 @@ type ContentFunctions = {
/** /**
* Removes a mod from a server. * Removes a mod from a server.
* @param contentType - The type of content to remove. * @param path - The path of the mod file.
* @param contentId - The ID of the content.
*/ */
remove: (contentType: ContentType, contentId: string) => Promise<void>; remove: (path: string) => Promise<void>;
/** /**
* Reinstalls a mod to a server. * Reinstalls a mod to a server.
* @param contentType - The type of content to reinstall. * @param replace - The path of the mod to replace.
* @param contentId - The ID of the content. * @param projectId - The ID of the content.
* @param newContentId - The ID of the new version. * @param versionId - The ID of the new version.
*/ */
reinstall: (contentType: ContentType, contentId: string, newContentId: string) => Promise<void>; reinstall: (replace: string, projectId: string, versionId: string) => Promise<void>;
}; };
type BackupFunctions = { type BackupFunctions = {
@@ -1370,12 +1537,44 @@ type FSFunctions = {
downloadFile: (path: string, raw?: boolean) => Promise<any>; downloadFile: (path: string, raw?: boolean) => Promise<any>;
}; };
type GeneralModule = General & GeneralFunctions; type ModuleError = {
type ContentModule = { data: Mod[] } & ContentFunctions; error: PyroServersFetchError;
type BackupsModule = { data: Backup[] } & BackupFunctions; timestamp: number;
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions; };
type StartupModule = Startup & StartupFunctions;
export type FSModule = { auth: JWTAuth } & FSFunctions; type GeneralModule = General &
GeneralFunctions & {
error?: ModuleError;
};
type ContentModule = {
data: Mod[];
error?: ModuleError;
} & ContentFunctions;
type BackupsModule = {
data: Backup[];
error?: ModuleError;
} & BackupFunctions;
type NetworkModule = {
allocations: Allocation[];
error?: ModuleError;
} & NetworkFunctions;
type StartupModule = Startup &
StartupFunctions & {
error?: ModuleError;
};
type WSModule = JWTAuth & {
error?: ModuleError;
};
type FSModule = {
auth: JWTAuth;
error?: ModuleError;
} & FSFunctions;
type ModulesMap = { type ModulesMap = {
general: GeneralModule; general: GeneralModule;
@@ -1383,7 +1582,7 @@ type ModulesMap = {
backups: BackupsModule; backups: BackupsModule;
network: NetworkModule; network: NetworkModule;
startup: StartupModule; startup: StartupModule;
ws: JWTAuth; ws: WSModule;
fs: FSModule; fs: FSModule;
}; };
@@ -1395,8 +1594,16 @@ export type Server<T extends avaliableModules> = {
/** /**
* Refreshes the included modules of the server * Refreshes the included modules of the server
* @param refreshModules - The modules to refresh. * @param refreshModules - The modules to refresh.
* @param options - The options to use when refreshing the modules.
*/ */
refresh: (refreshModules?: avaliableModules) => Promise<void>; refresh: (
refreshModules?: avaliableModules,
options?: {
preserveConnection?: boolean;
preserveInstallState?: boolean;
},
) => Promise<void>;
loadModules: (modulesToLoad: avaliableModules) => Promise<void>;
setError: (error: Error) => void; setError: (error: Error) => void;
error?: Error; error?: Error;
serverId: string; serverId: string;
@@ -1404,49 +1611,103 @@ export type Server<T extends avaliableModules> = {
export const usePyroServer = async (serverId: string, includedModules: avaliableModules) => { export const usePyroServer = async (serverId: string, includedModules: avaliableModules) => {
const server: Server<typeof includedModules> = reactive({ const server: Server<typeof includedModules> = reactive({
refresh: async (refreshModules?: avaliableModules) => { refresh: async (
const promises: Promise<void>[] = []; refreshModules?: avaliableModules,
if (refreshModules) { options?: {
for (const module of refreshModules) { preserveConnection?: boolean;
promises.push( preserveInstallState?: boolean;
(async () => { },
const mods = modules[module]; ) => {
if (mods.get) { if (server.general?.status === "installing" && !refreshModules) {
const data = await mods.get(serverId); return;
server[module] = { ...server[module], ...data }; }
}
})(), const modulesToRefresh = [...new Set(refreshModules || includedModules)];
); const serverError = new PyroServerError();
const modulePromises = modulesToRefresh.map(async (module) => {
try {
const mods = modules[module];
if (!mods?.get) return;
const data = await mods.get(serverId);
if (!data) return;
if (module === "general" && options?.preserveConnection) {
server[module] = {
...server[module],
...data,
image: server[module]?.image || data.image,
motd: server[module]?.motd || data.motd,
status:
options.preserveInstallState && server[module]?.status === "installing"
? "installing"
: data.status,
};
} else {
server[module] = { ...server[module], ...data };
}
} catch (error) {
console.error(`Failed to refresh module ${module}:`, error);
if (error instanceof Error) {
serverError.addError(module, error);
}
} }
} else { });
for (const module of includedModules) {
promises.push( await Promise.allSettled(modulePromises);
(async () => {
const mods = modules[module]; if (serverError.hasErrors()) {
if (mods.get) { if (server.error && server.error instanceof PyroServerError) {
const data = await mods.get(serverId); serverError.errors.forEach((error, module) => {
server[module] = { ...server[module], ...data }; (server.error as PyroServerError).addError(module, error);
} });
})(), } else {
); server.setError(serverError);
} }
} }
await Promise.all(promises); },
loadModules: async (modulesToLoad: avaliableModules) => {
const newModules = modulesToLoad.filter((module) => !server[module]);
if (newModules.length === 0) return;
newModules.forEach((module) => {
server[module] = modules[module];
});
await server.refresh(newModules);
}, },
setError: (error: Error) => { setError: (error: Error) => {
server.error = error; if (!server.error) {
server.error = error;
} else if (error instanceof PyroServerError) {
if (!(server.error instanceof PyroServerError)) {
const newError = new PyroServerError();
newError.addError("previous", server.error);
server.error = newError;
}
error.errors.forEach((err, module) => {
(server.error as PyroServerError).addError(module, err);
});
}
}, },
serverId, serverId,
}); });
for (const module of includedModules) { const initialModules = includedModules.filter((module) => ["general", "ws"].includes(module));
const mods = modules[module]; const deferredModules = includedModules.filter((module) => !["general", "ws"].includes(module));
server[module] = mods;
} initialModules.forEach((module) => {
server[module] = modules[module];
});
internalServerRefrence.value = server; internalServerRefrence.value = server;
await server.refresh(initialModules);
await server.refresh(); if (deferredModules.length > 0) {
await server.loadModules(deferredModules);
}
return server as Server<typeof includedModules>; return server as Server<typeof includedModules>;
}; };

View File

@@ -5,6 +5,27 @@
<div class="pointer-events-none absolute inset-0 z-[-1]"> <div class="pointer-events-none absolute inset-0 z-[-1]">
<div id="absolute-background-teleport" class="relative"></div> <div id="absolute-background-teleport" class="relative"></div>
</div> </div>
<div class="pointer-events-none absolute inset-0 z-50">
<div
class="over-the-top-random-animation"
:style="{ '--_r-count': rCount }"
:class="{ threshold: rCount > 20, 'rings-expand': rCount >= 40 }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight text-9xl font-extrabold text-contrast"
>
?
</div>
</div>
</div>
</div>
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }"> <div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
<div <div
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'" v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
@@ -206,7 +227,6 @@
<template #modpacks> <PackageOpenIcon aria-hidden="true" /> Modpacks </template> <template #modpacks> <PackageOpenIcon aria-hidden="true" /> Modpacks </template>
</TeleportOverflowMenu> </TeleportOverflowMenu>
</ButtonStyled> </ButtonStyled>
<ButtonStyled <ButtonStyled
type="transparent" type="transparent"
:highlighted=" :highlighted="
@@ -231,14 +251,52 @@
</ButtonStyled> </ButtonStyled>
</template> </template>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-1">
<ButtonStyled type="transparent">
<OverflowMenu
v-if="auth.user && isStaff(auth.user)"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
position="bottom"
direction="left"
:dropdown-id="`${basePopoutId}-staff`"
aria-label="Create new..."
:options="[
{
id: 'review-projects',
color: 'orange',
link: '/moderation/review',
},
{
id: 'review-reports',
color: 'orange',
link: '/moderation/reports',
},
{
divider: true,
shown: isAdmin(auth.user),
},
{
id: 'user-lookup',
color: 'primary',
link: '/admin/user_email',
shown: isAdmin(auth.user),
},
]"
>
<ModrinthIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #review-projects> <ScaleIcon aria-hidden="true" /> Review projects </template>
<template #review-reports> <ReportIcon aria-hidden="true" /> Reports </template>
<template #user-lookup> <UserIcon aria-hidden="true" /> Lookup by email </template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled type="transparent"> <ButtonStyled type="transparent">
<OverflowMenu <OverflowMenu
v-if="auth.user" v-if="auth.user"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1" class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
position="bottom" position="bottom"
direction="left" direction="left"
:dropdown-id="createPopoutId" :dropdown-id="`${basePopoutId}-create`"
aria-label="Create new..." aria-label="Create new..."
:options="[ :options="[
{ {
@@ -270,7 +328,7 @@
</ButtonStyled> </ButtonStyled>
<OverflowMenu <OverflowMenu
v-if="auth.user" v-if="auth.user"
:dropdown-id="userPopoutId" :dropdown-id="`${basePopoutId}-user`"
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1" class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
:options="userMenuOptions" :options="userMenuOptions"
> >
@@ -291,15 +349,22 @@
</template> </template>
<template #revenue> <CurrencyIcon aria-hidden="true" /> Revenue </template> <template #revenue> <CurrencyIcon aria-hidden="true" /> Revenue </template>
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template> <template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
<template #moderation> <ModerationIcon aria-hidden="true" /> Moderation </template> <template #moderation> <ScaleIcon aria-hidden="true" /> Moderation </template>
<template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template> <template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template>
</OverflowMenu> </OverflowMenu>
<ButtonStyled v-else color="brand"> <template v-else>
<nuxt-link to="/auth/sign-in"> <ButtonStyled color="brand">
<LogInIcon aria-hidden="true" /> <nuxt-link to="/auth/sign-in">
Sign in <LogInIcon aria-hidden="true" />
</nuxt-link> Sign in
</ButtonStyled> </nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link v-tooltip="'Settings'" to="/settings">
<SettingsIcon aria-label="Settings" />
</nuxt-link>
</ButtonStyled>
</template>
</div> </div>
</header> </header>
<header class="mobile-navigation mobile-only"> <header class="mobile-navigation mobile-only">
@@ -371,7 +436,7 @@
class="iconified-button" class="iconified-button"
to="/moderation" to="/moderation"
> >
<ModerationIcon aria-hidden="true" /> <ScaleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.moderationLabel) }} {{ formatMessage(commonMessages.moderationLabel) }}
</NuxtLink> </NuxtLink>
<NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags"> <NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags">
@@ -432,7 +497,7 @@
} }
" "
> >
<NotificationIcon aria-hidden="true" /> <BellIcon aria-hidden="true" />
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/dashboard" to="/dashboard"
@@ -451,7 +516,7 @@
> >
<template v-if="!auth.user"> <template v-if="!auth.user">
<HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" /> <HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" />
<CrossIcon v-else aria-hidden="true" /> <XIcon v-else aria-hidden="true" />
</template> </template>
<template v-else> <template v-else>
<Avatar <Avatar
@@ -466,108 +531,102 @@
</button> </button>
</div> </div>
</header> </header>
<main> <main class="min-h-[calc(100vh-4.5rem-310.59px)]">
<ModalCreation v-if="auth.user" ref="modal_creation" /> <ModalCreation v-if="auth.user" ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" /> <CollectionCreateModal ref="modal_collection_creation" />
<OrganizationCreateModal ref="modal_organization_creation" /> <OrganizationCreateModal ref="modal_organization_creation" />
<slot id="main" /> <slot id="main" />
</main> </main>
<footer> <footer
<div class="logo-info" role="region" aria-label="Modrinth information"> class="footer-brand-background experimental-styles-within mt-6 border-0 border-t-[1px] border-solid"
<BrandTextLogo >
aria-hidden="true" <div class="mx-auto flex max-w-screen-xl flex-col gap-6 p-6 pb-12 sm:px-12 md:py-12">
class="text-logo button-base mx-auto mb-4 lg:mx-0" <div
@click="developerModeIncrement()" class="grid grid-cols-1 gap-4 text-primary md:grid-cols-[1fr_2fr] lg:grid-cols-[auto_auto_auto_auto_auto]"
/> >
<p class="mb-4"> <div
<IntlFormatted :message-id="footerMessages.openSource"> class="flex flex-col items-center gap-3 md:items-start"
<template #github-link="{ children }"> role="region"
<a aria-label="Modrinth information"
:target="$external()"
href="https://github.com/modrinth"
class="text-link"
rel="noopener"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<p class="mb-4">
{{ config.public.branch }}@<a
:target="$external()"
:href="
'https://github.com/' +
config.public.owner +
'/' +
config.public.slug +
'/tree/' +
config.public.hash
"
class="text-link"
rel="noopener"
>{{ config.public.hash.substring(0, 7) }}</a
> >
</p> <BrandTextLogo
<p>© Rinth, Inc.</p> aria-hidden="true"
</div> class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
<div class="links links-1" role="region" aria-label="Legal"> @click="developerModeIncrement()"
<h4 aria-hidden="true">{{ formatMessage(footerMessages.companyTitle) }}</h4> />
<nuxt-link to="/legal/terms"> {{ formatMessage(footerMessages.terms) }}</nuxt-link> <div class="flex flex-wrap justify-center gap-px sm:-mx-2">
<nuxt-link to="/legal/privacy"> {{ formatMessage(footerMessages.privacy) }}</nuxt-link> <ButtonStyled
<nuxt-link to="/legal/rules"> {{ formatMessage(footerMessages.rules) }}</nuxt-link> v-for="(social, index) in socialLinks"
<a :target="$external()" href="https://careers.modrinth.com"> :key="`footer-social-${index}`"
{{ formatMessage(footerMessages.careers) }} circular
<span v-if="false" class="count-bubble">0</span> type="transparent"
</a> >
</div> <a
<div class="links links-2" role="region" aria-label="Resources"> v-tooltip="social.label"
<h4 aria-hidden="true">{{ formatMessage(footerMessages.resourcesTitle) }}</h4> :href="social.href"
<a :target="$external()" href="https://support.modrinth.com"> target="_blank"
{{ formatMessage(footerMessages.support) }} :rel="`noopener${social.rel ? ` ${social.rel}` : ''}`"
</a> >
<a :target="$external()" href="https://blog.modrinth.com"> <component :is="social.icon" class="h-5 w-5" />
{{ formatMessage(footerMessages.blog) }} </a>
</a> </ButtonStyled>
<a :target="$external()" href="https://docs.modrinth.com"> </div>
{{ formatMessage(footerMessages.docs) }} <div class="mt-auto flex flex-wrap justify-center gap-3 md:flex-col">
</a> <p class="m-0">
<a :target="$external()" href="https://status.modrinth.com"> <IntlFormatted :message-id="footerMessages.openSource">
{{ formatMessage(footerMessages.status) }} <template #github-link="{ children }">
</a> <a
</div> href="https://github.com/modrinth/code"
<div class="links links-3" role="region" aria-label="Interact"> class="text-brand hover:underline"
<h4 aria-hidden="true">{{ formatMessage(footerMessages.interactTitle) }}</h4> target="_blank"
<a rel="noopener" :target="$external()" href="https://discord.modrinth.com"> Discord </a> rel="noopener"
<a rel="noopener" :target="$external()" href="https://x.com/modrinth"> X (Twitter) </a> >
<a rel="noopener" :target="$external()" href="https://floss.social/@modrinth"> Mastodon </a> <component :is="() => children" />
<a rel="noopener" :target="$external()" href="https://crowdin.com/project/modrinth"> </a>
Crowdin </template>
</a> </IntlFormatted>
</div> </p>
<div class="buttons"> <p class="m-0">© 2025 Rinth, Inc.</p>
<nuxt-link class="btn btn-outline btn-primary" to="/app"> </div>
<DownloadIcon aria-hidden="true" /> </div>
{{ formatMessage(messages.getModrinthApp) }} <div class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:contents">
</nuxt-link> <div
<button class="iconified-button raised-button" @click="changeTheme"> v-for="group in footerLinks"
<MoonIcon v-if="$theme.active === 'light'" aria-hidden="true" /> :key="group.label"
<SunIcon v-else aria-hidden="true" /> class="flex flex-col items-center gap-3 sm:items-start"
{{ formatMessage(messages.changeTheme) }} >
</button> <h3 class="m-0 text-base text-contrast">{{ group.label }}</h3>
<nuxt-link class="iconified-button raised-button" to="/settings"> <template v-for="item in group.links" :key="item.label">
<SettingsIcon aria-hidden="true" /> <nuxt-link
{{ formatMessage(commonMessages.settingsLabel) }} v-if="item.href.startsWith('/')"
</nuxt-link> :to="item.href"
</div> class="w-fit hover:underline"
<div class="not-affiliated-notice"> >
{{ formatMessage(footerMessages.legalDisclaimer) }} {{ item.label }}
</nuxt-link>
<a
v-else
:href="item.href"
class="w-fit hover:underline"
target="_blank"
rel="noopener"
>
{{ item.label }}
</a>
</template>
</div>
</div>
</div>
<div class="flex justify-center text-center text-xs font-medium text-secondary opacity-50">
{{ formatMessage(footerMessages.legalDisclaimer) }}
</div>
</div> </div>
</footer> </footer>
</div> </div>
</template> </template>
<script setup> <script setup>
import { import {
ModrinthIcon,
ArrowBigUpDashIcon, ArrowBigUpDashIcon,
BookmarkIcon, BookmarkIcon,
ServerIcon, ServerIcon,
@@ -599,12 +658,17 @@ import {
GlassesIcon, GlassesIcon,
PaintBrushIcon, PaintBrushIcon,
PackageOpenIcon, PackageOpenIcon,
XIcon as CrossIcon, DiscordIcon,
ScaleIcon as ModerationIcon, BlueskyIcon,
BellIcon as NotificationIcon, TumblrIcon,
TwitterIcon,
MastodonIcon,
GitHubIcon,
ScaleIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui"; import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui";
import { isAdmin, isStaff } from "@modrinth/utils";
import ModalCreation from "~/components/ui/ModalCreation.vue"; import ModalCreation from "~/components/ui/ModalCreation.vue";
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts"; import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue"; import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
@@ -622,10 +686,10 @@ const flags = useFeatureFlags();
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const route = useNativeRoute(); const route = useNativeRoute();
const router = useNativeRouter();
const link = config.public.siteUrl + route.path.replace(/\/+$/, ""); const link = config.public.siteUrl + route.path.replace(/\/+$/, "");
const createPopoutId = useId(); const basePopoutId = useId();
const userPopoutId = useId();
const verifyEmailBannerMessages = defineMessages({ const verifyEmailBannerMessages = defineMessages({
title: { title: {
@@ -708,50 +772,6 @@ const footerMessages = defineMessages({
id: "layout.footer.open-source", id: "layout.footer.open-source",
defaultMessage: "Modrinth is <github-link>open source</github-link>.", defaultMessage: "Modrinth is <github-link>open source</github-link>.",
}, },
companyTitle: {
id: "layout.footer.company.title",
defaultMessage: "Company",
},
terms: {
id: "layout.footer.company.terms",
defaultMessage: "Terms",
},
privacy: {
id: "layout.footer.company.privacy",
defaultMessage: "Privacy",
},
rules: {
id: "layout.footer.company.rules",
defaultMessage: "Rules",
},
careers: {
id: "layout.footer.company.careers",
defaultMessage: "Careers",
},
resourcesTitle: {
id: "layout.footer.resources.title",
defaultMessage: "Resources",
},
support: {
id: "layout.footer.resources.support",
defaultMessage: "Support",
},
blog: {
id: "layout.footer.resources.blog",
defaultMessage: "Blog",
},
docs: {
id: "layout.footer.resources.docs",
defaultMessage: "Docs",
},
status: {
id: "layout.footer.resources.status",
defaultMessage: "Status",
},
interactTitle: {
id: "layout.footer.interact.title",
defaultMessage: "Interact",
},
legalDisclaimer: { legalDisclaimer: {
id: "layout.footer.legal-disclaimer", id: "layout.footer.legal-disclaimer",
defaultMessage: defaultMessage:
@@ -928,12 +948,57 @@ const isDiscoveringSubpage = computed(
() => route.name && route.name.startsWith("type-id") && !route.query.sid, () => route.name && route.name.startsWith("type-id") && !route.query.sid,
); );
const rCount = ref(0);
const randomProjects = ref([]);
const disableRandomProjects = ref(false);
const disableRandomProjectsForRoute = computed(
() =>
route.name.startsWith("servers") ||
route.name.includes("settings") ||
route.name.includes("admin"),
);
async function onKeyDown(event) {
if (disableRandomProjects.value || disableRandomProjectsForRoute.value) {
return;
}
if (event.key === "r") {
rCount.value++;
if (randomProjects.value.length < 3) {
randomProjects.value = await useBaseFetch("projects_random?count=50").catch((err) => {
console.error(err);
return [];
});
}
}
if (rCount.value >= 40) {
rCount.value = 0;
const randomProject = randomProjects.value[0];
await router.push(`/project/${randomProject.slug}`);
randomProjects.value.splice(0, 1);
}
}
function onKeyUp(event) {
if (event.key === "r") {
rCount.value = 0;
}
}
onMounted(() => { onMounted(() => {
if (window && import.meta.client) { if (window && import.meta.client) {
window.history.scrollRestoration = "auto"; window.history.scrollRestoration = "auto";
} }
runAnalytics(); runAnalytics();
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
}); });
watch( watch(
@@ -1023,6 +1088,194 @@ const { cycle: changeTheme } = useTheme();
function hideStagingBanner() { function hideStagingBanner() {
cosmetics.value.hideStagingBanner = true; cosmetics.value.hideStagingBanner = true;
} }
const socialLinks = [
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.discord", defaultMessage: "Discord" }),
),
href: "https://discord.modrinth.com",
icon: DiscordIcon,
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.bluesky", defaultMessage: "Bluesky" }),
),
href: "https://bsky.app/profile/modrinth.com",
icon: BlueskyIcon,
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.mastodon", defaultMessage: "Mastodon" }),
),
href: "https://floss.social/@modrinth",
icon: MastodonIcon,
rel: "me",
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
),
href: "https://tumblr.com/modrinth",
icon: TumblrIcon,
},
{
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
href: "https://x.com/modrinth",
icon: TwitterIcon,
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
),
href: "https://github.com/modrinth",
icon: GitHubIcon,
},
];
const footerLinks = [
{
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
links: [
{
href: "https://blog.modrinth.com",
label: formatMessage(
defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
),
},
{
href: "/news/changelog",
label: formatMessage(
defineMessage({ id: "layout.footer.about.changelog", defaultMessage: "Changelog" }),
),
},
{
href: "https://status.modrinth.com",
label: formatMessage(
defineMessage({ id: "layout.footer.about.status", defaultMessage: "Status" }),
),
},
{
href: "https://careers.modrinth.com",
label: formatMessage(
defineMessage({ id: "layout.footer.about.careers", defaultMessage: "Careers" }),
),
},
{
href: "/legal/cmp-info",
label: formatMessage(
defineMessage({
id: "layout.footer.about.rewards-program",
defaultMessage: "Rewards Program",
}),
),
},
],
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.products", defaultMessage: "Products" }),
),
links: [
{
href: "/plus",
label: formatMessage(
defineMessage({ id: "layout.footer.products.plus", defaultMessage: "Modrinth+" }),
),
},
{
href: "/app",
label: formatMessage(
defineMessage({ id: "layout.footer.products.app", defaultMessage: "Modrinth App" }),
),
},
{
href: "/servers",
label: formatMessage(
defineMessage({
id: "layout.footer.products.servers",
defaultMessage: "Modrinth Servers",
}),
),
},
],
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.resources", defaultMessage: "Resources" }),
),
links: [
{
href: "https://support.modrinth.com",
label: formatMessage(
defineMessage({
id: "layout.footer.resources.help-center",
defaultMessage: "Help Center",
}),
),
},
{
href: "https://crowdin.com/project/modrinth",
label: formatMessage(
defineMessage({ id: "layout.footer.resources.translate", defaultMessage: "Translate" }),
),
},
{
href: "https://github.com/modrinth/code/issues",
label: formatMessage(
defineMessage({
id: "layout.footer.resources.report-issues",
defaultMessage: "Report issues",
}),
),
},
{
href: "https://docs.modrinth.com/api/",
label: formatMessage(
defineMessage({
id: "layout.footer.resources.api-docs",
defaultMessage: "API documentation",
}),
),
},
],
},
{
label: formatMessage(defineMessage({ id: "layout.footer.legal", defaultMessage: "Legal" })),
links: [
{
href: "/legal/rules",
label: formatMessage(
defineMessage({ id: "layout.footer.legal.rules", defaultMessage: "Content Rules" }),
),
},
{
href: "/legal/terms",
label: formatMessage(
defineMessage({ id: "layout.footer.legal.terms-of-use", defaultMessage: "Terms of Use" }),
),
},
{
href: "/legal/privacy",
label: formatMessage(
defineMessage({
id: "layout.footer.legal.privacy-policy",
defaultMessage: "Privacy Policy",
}),
),
},
{
href: "/legal/security",
label: formatMessage(
defineMessage({
id: "layout.footer.legal.security-notice",
defaultMessage: "Security Notice",
}),
),
},
],
},
];
</script> </script>
<style lang="scss"> <style lang="scss">
@@ -1037,127 +1290,9 @@ function hideStagingBanner() {
min-height: calc(100vh - var(--spacing-card-bg)); min-height: calc(100vh - var(--spacing-card-bg));
} }
@media screen and (max-width: 750px) {
margin-bottom: calc(var(--size-mobile-navbar-height) + 2rem);
}
main { main {
grid-area: main; grid-area: main;
} }
footer {
margin: 6rem 0 2rem 0;
text-align: center;
display: grid;
grid-template:
"logo-info logo-info logo-info" auto
"links-1 links-2 links-3" auto
"buttons buttons buttons" auto
"notice notice notice" auto
/ 1fr 1fr 1fr;
max-width: 1280px;
.logo-info {
margin-left: auto;
margin-right: auto;
max-width: 15rem;
margin-bottom: 1rem;
grid-area: logo-info;
.text-logo {
width: 10rem;
height: auto;
}
}
.links {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
h4 {
color: var(--color-text-dark);
margin: 0 0 1rem 0;
}
a {
margin: 0 0 1rem 0;
}
&.links-1 {
grid-area: links-1;
}
&.links-2 {
grid-area: links-2;
}
&.links-3 {
grid-area: links-3;
}
.count-bubble {
font-size: 1rem;
border-radius: 5rem;
background: var(--color-brand);
color: var(--color-text-inverted);
padding: 0 0.35rem;
margin-left: 0.25rem;
}
}
.buttons {
margin-left: auto;
margin-right: auto;
grid-area: buttons;
button,
a {
margin-bottom: 0.5rem;
margin-left: auto;
margin-right: auto;
}
}
.not-affiliated-notice {
grid-area: notice;
font-size: var(--font-size-xs);
text-align: center;
font-weight: 500;
margin-top: var(--spacing-card-md);
}
@media screen and (min-width: 1024px) {
display: grid;
margin-inline: auto;
grid-template:
"logo-info links-1 links-2 links-3 buttons" auto
"notice notice notice notice notice" auto;
text-align: unset;
.logo-info {
margin-right: 4rem;
}
.links {
margin-right: 4rem;
}
.buttons {
width: unset;
margin-left: 0;
button,
a {
margin-right: unset;
}
}
.not-affiliated-notice {
margin-top: 0;
}
}
}
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@@ -1444,9 +1579,120 @@ function hideStagingBanner() {
.mobile-navigation { .mobile-navigation {
display: flex; display: flex;
} }
}
main { .footer-brand-background {
padding-top: 1.5rem; background: var(--brand-gradient-strong-bg);
border-color: var(--brand-gradient-border);
}
.over-the-top-random-animation {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 0;
animation:
tilt-shaking calc(0.2s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite,
translate-x-shaking calc(0.3s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite,
translate-y-shaking calc(0.25s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite;
&.threshold {
opacity: 1;
}
&.rings-expand {
scale: 0.8;
opacity: 0;
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> * {
position: absolute;
scale: calc(1 + max((var(--_r-count) - 20), 0) * 0.1);
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
}
@keyframes tilt-shaking {
0% {
rotate: 0deg;
}
25% {
rotate: calc(1deg * (var(--_r-count) - 20));
}
50% {
rotate: 0deg;
}
75% {
rotate: calc(-1deg * (var(--_r-count) - 20));
}
100% {
rotate: 0deg;
}
}
@keyframes translate-x-shaking {
0% {
translate: 0;
}
25% {
translate: calc(2px * (var(--_r-count) - 20));
}
50% {
translate: 0;
}
75% {
translate: calc(-2px * (var(--_r-count) - 20));
}
100% {
translate: 0;
}
}
@keyframes translate-y-shaking {
0% {
transform: translateY(0);
}
25% {
transform: translateY(calc(2px * (var(--_r-count) - 20)));
}
50% {
transform: translateY(0);
}
75% {
transform: translateY(calc(-2px * (var(--_r-count) - 20)));
}
100% {
transform: translateY(0);
} }
} }
</style> </style>

View File

@@ -287,45 +287,90 @@
"layout.banner.verify-email.title": { "layout.banner.verify-email.title": {
"message": "For security purposes, please verify your email address on Modrinth." "message": "For security purposes, please verify your email address on Modrinth."
}, },
"layout.footer.company.careers": { "layout.footer.about": {
"message": "About"
},
"layout.footer.about.blog": {
"message": "Blog"
},
"layout.footer.about.careers": {
"message": "Careers" "message": "Careers"
}, },
"layout.footer.company.privacy": { "layout.footer.about.changelog": {
"message": "Privacy" "message": "Changelog"
}, },
"layout.footer.company.rules": { "layout.footer.about.rewards-program": {
"message": "Rules" "message": "Rewards Program"
}, },
"layout.footer.company.terms": { "layout.footer.about.status": {
"message": "Terms" "message": "Status"
}, },
"layout.footer.company.title": { "layout.footer.legal": {
"message": "Company" "message": "Legal"
},
"layout.footer.interact.title": {
"message": "Interact"
}, },
"layout.footer.legal-disclaimer": { "layout.footer.legal-disclaimer": {
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT." "message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
}, },
"layout.footer.legal.privacy-policy": {
"message": "Privacy Policy"
},
"layout.footer.legal.rules": {
"message": "Content Rules"
},
"layout.footer.legal.security-notice": {
"message": "Security Notice"
},
"layout.footer.legal.terms-of-use": {
"message": "Terms of Use"
},
"layout.footer.open-source": { "layout.footer.open-source": {
"message": "Modrinth is <github-link>open source</github-link>." "message": "Modrinth is <github-link>open source</github-link>."
}, },
"layout.footer.resources.blog": { "layout.footer.products": {
"message": "Blog" "message": "Products"
}, },
"layout.footer.resources.docs": { "layout.footer.products.app": {
"message": "Docs" "message": "Modrinth App"
}, },
"layout.footer.resources.status": { "layout.footer.products.plus": {
"message": "Status" "message": "Modrinth+"
}, },
"layout.footer.resources.support": { "layout.footer.products.servers": {
"message": "Support" "message": "Modrinth Servers"
}, },
"layout.footer.resources.title": { "layout.footer.resources": {
"message": "Resources" "message": "Resources"
}, },
"layout.footer.resources.api-docs": {
"message": "API documentation"
},
"layout.footer.resources.help-center": {
"message": "Help Center"
},
"layout.footer.resources.report-issues": {
"message": "Report issues"
},
"layout.footer.resources.translate": {
"message": "Translate"
},
"layout.footer.social.bluesky": {
"message": "Bluesky"
},
"layout.footer.social.discord": {
"message": "Discord"
},
"layout.footer.social.github": {
"message": "GitHub"
},
"layout.footer.social.mastodon": {
"message": "Mastodon"
},
"layout.footer.social.tumblr": {
"message": "Tumblr"
},
"layout.footer.social.x": {
"message": "X"
},
"layout.menu-toggle.action": { "layout.menu-toggle.action": {
"message": "Toggle menu" "message": "Toggle menu"
}, },
@@ -521,6 +566,9 @@
"report.not-for.bug-reports": { "report.not-for.bug-reports": {
"message": "Bug reports" "message": "Bug reports"
}, },
"report.not-for.bug-reports.description": {
"message": "You can report bugs to their <issues-link>issue tracker</issues-link>."
},
"report.not-for.dmca": { "report.not-for.dmca": {
"message": "DMCA takedowns" "message": "DMCA takedowns"
}, },

View File

@@ -460,6 +460,10 @@
class="new-page sidebar" class="new-page sidebar"
:class="{ :class="{
'alt-layout': cosmetics.leftContentLayout, 'alt-layout': cosmetics.leftContentLayout,
'ultimate-sidebar':
showModerationChecklist &&
!collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup,
}" }"
> >
<div class="normal-page__header relative my-4"> <div class="normal-page__header relative my-4">
@@ -674,7 +678,7 @@
:auth="auth" :auth="auth"
:tags="tags" :tags="tags"
/> />
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="mb-4"> <MessageBanner v-if="project.status === 'archived'" message-type="warning" class="my-4">
{{ project.title }} has been archived. {{ project.title }} will not receive any further {{ project.title }} has been archived. {{ project.title }} will not receive any further
updates unless the author decides to unarchive the project. updates unless the author decides to unarchive the project.
</MessageBanner> </MessageBanner>
@@ -805,13 +809,18 @@
@delete-version="deleteVersion" @delete-version="deleteVersion"
/> />
</div> </div>
<div class="normal-page__ultimate-sidebar">
<ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
:project="project"
:future-projects="futureProjects"
:reset-project="resetProject"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
</div> </div>
<ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
:project="project"
:future-projects="futureProjects"
:reset-project="resetProject"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
@@ -1431,6 +1440,7 @@ async function copyId() {
const collapsedChecklist = ref(false); const collapsedChecklist = ref(false);
const showModerationChecklist = ref(false); const showModerationChecklist = ref(false);
const collapsedModerationChecklist = ref(false);
const futureProjects = ref([]); const futureProjects = ref([]);
if (import.meta.client && history && history.state && history.state.showChecklist) { if (import.meta.client && history && history.state && history.state.showChecklist) {
showModerationChecklist.value = true; showModerationChecklist.value = true;

View File

@@ -8,21 +8,25 @@
<span class="label__subdescription"> <span class="label__subdescription">
The description must clearly and honestly describe the purpose and function of the The description must clearly and honestly describe the purpose and function of the
project. See section 2.1 of the project. See section 2.1 of the
<nuxt-link to="/legal/rules" class="text-link" target="_blank">Content Rules</nuxt-link> <nuxt-link class="text-link" target="_blank" to="/legal/rules">Content Rules</nuxt-link>
for the full requirements. for the full requirements.
</span> </span>
</span> </span>
</div> </div>
<MarkdownEditor <MarkdownEditor
v-model="description" v-model="description"
:disabled="
!currentMember ||
(currentMember.permissions & TeamMemberPermission.EDIT_BODY) !==
TeamMemberPermission.EDIT_BODY
"
:on-image-upload="onUploadHandler" :on-image-upload="onUploadHandler"
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
/> />
<div class="input-group markdown-disclaimer"> <div class="input-group markdown-disclaimer">
<button <button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges" :disabled="!hasChanges"
class="iconified-button brand-button"
type="button"
@click="saveChanges()" @click="saveChanges()"
> >
<SaveIcon /> <SaveIcon />
@@ -33,91 +37,50 @@
</div> </div>
</template> </template>
<script> <script lang="ts" setup>
import { SaveIcon } from "@modrinth/assets";
import { MarkdownEditor } from "@modrinth/ui"; import { MarkdownEditor } from "@modrinth/ui";
import Chips from "~/components/ui/Chips.vue"; import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
import SaveIcon from "~/assets/images/utils/save.svg?component"; import { computed, ref } from "vue";
import { renderHighlightedString } from "~/helpers/highlight.js";
import { useImageUpload } from "~/composables/image-upload.ts"; import { useImageUpload } from "~/composables/image-upload.ts";
export default defineNuxtComponent({ const props = defineProps<{
components: { project: Project;
Chips, allMembers: TeamMember[];
SaveIcon, currentMember: TeamMember | undefined;
MarkdownEditor, patchProject: (payload: object, quiet?: boolean) => object;
}, }>();
props: {
project: {
type: Object,
default() {
return {};
},
},
allMembers: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default() {
return null;
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: "main",
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
};
},
},
},
data() {
return {
description: this.project.body,
bodyViewMode: "source",
};
},
computed: {
patchData() {
const data = {};
if (this.description !== this.project.body) { const description = ref(props.project.body);
data.body = this.description;
}
return data; const patchRequestPayload = computed(() => {
}, const payload: {
hasChanges() { body?: string;
return Object.keys(this.patchData).length > 0; } = {};
},
}, if (description.value !== props.project.body) {
created() { payload.body = description.value;
this.EDIT_BODY = 1 << 3; }
},
methods: { return payload;
renderHighlightedString,
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData);
}
},
async onUploadHandler(file) {
const response = await useImageUpload(file, {
context: "project",
projectID: this.project.id,
});
return response.url;
},
},
}); });
const hasChanges = computed(() => {
return Object.keys(patchRequestPayload.value).length > 0;
});
function saveChanges() {
props.patchProject(patchRequestPayload.value);
}
async function onUploadHandler(file: File) {
const response = await useImageUpload(file, {
context: "project",
projectID: props.project.id,
});
return response.url;
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,61 +1,128 @@
<template> <template>
<div> <div>
<section class="universal-card"> <section class="universal-card">
<h2 class="label__title size-card-header">License</h2>
<p class="label__description">
It is important to choose a proper license for your
{{ 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/"
target="_blank"
rel="noopener"
class="text-link"
>
licensing guide
</a>
for more information.
</p>
<div class="adjacent-input"> <div class="adjacent-input">
<label for="license-multiselect"> <label for="license-multiselect">
<span class="label__title size-card-header">License</span> <span class="label__title">Select a license</span>
<span class="label__description"> <span class="label__description">
It is very important to choose a proper license for your How users are and aren't allowed to use your project.
{{ $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.
<span v-if="license && license.friendly === 'Custom'" class="label__subdescription">
Enter a valid
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
SPDX license identifier</a
>
in the marked area. If your license does not have a SPDX identifier (for example, if
you created the license yourself or if the license is Minecraft-specific), simply
check the box and enter the name of the license instead.
</span>
<span class="label__subdescription">
Confused? See our
<a
href="https://blog.modrinth.com/licensing-guide/"
target="_blank"
rel="noopener"
class="text-link"
>
licensing guide</a
>
for more information.
</span>
</span> </span>
</label> </label>
<div class="input-stack">
<Multiselect <div class="w-1/2">
id="license-multiselect" <DropdownSelect
v-model="license" v-model="license"
name="License selector"
:options="builtinLicenses"
:display-name="(chosen: BuiltinLicense) => chosen.friendly"
placeholder="Select license..." placeholder="Select license..."
track-by="short" />
label="friendly" </div>
:options="defaultLicenses" </div>
:searchable="true"
:close-on-select="true" <div class="adjacent-input" v-if="license.requiresOnlyOrLater">
:show-labels="false" <label for="or-later-checkbox">
:class="{ <span class="label__title">Later editions</span>
'known-error': license?.short === '' && showKnownErrors, <span class="label__description">
}" The license you selected has an "or later" clause. If you check this box, users may use
your project under later editions of the license.
</span>
</label>
<Checkbox
id="or-later-checkbox"
v-model="allowOrLater"
:disabled="!hasPermission"
description="Allow later editions"
class="w-1/2"
>
Allow later editions
</Checkbox>
</div>
<div class="adjacent-input">
<label for="license-url">
<span class="label__title">License URL</span>
<span class="label__description" v-if="license?.friendly !== 'Custom'">
The web location of the full license text. If you don't provide a link, the license text
will be displayed instead.
</span>
<span class="label__description" v-else>
The web location of the full license text. You have to provide a link since this is a
custom license.
</span>
</label>
<div class="w-1/2">
<input
id="license-url"
v-model="licenseUrl"
type="url"
maxlength="2048"
:placeholder="license?.friendly !== 'Custom' ? `License URL (optional)` : `License URL`"
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
class="w-full"
/>
</div>
</div>
<div class="adjacent-input" v-if="license?.friendly === 'Custom'">
<label for="license-spdx" v-if="!nonSpdxLicense">
<span class="label__title">SPDX identifier</span>
<span class="label__description">
If your license does not have an offical
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
SPDX license identifier</a
>, check the box and enter the name of the license instead.
</span>
</label>
<label for="license-name" v-else>
<span class="label__title">License name</span>
<span class="label__description"
>The full name of the license. If the license has a SPDX identifier, please uncheck the
checkbox and use the identifier instead.</span
>
</label>
<div class="input-stack w-1/2">
<input
v-if="!nonSpdxLicense"
v-model="license.short"
id="license-spdx"
class="w-full"
type="text"
maxlength="128"
placeholder="SPDX identifier"
:disabled="!hasPermission" :disabled="!hasPermission"
/> />
<Checkbox <input
v-if="license?.requiresOnlyOrLater" v-else
v-model="allowOrLater" v-model="license.short"
id="license-name"
class="w-full"
type="text"
maxlength="128"
placeholder="License name"
:disabled="!hasPermission" :disabled="!hasPermission"
description="Allow later editions of this license" />
>
Allow later editions of this license
</Checkbox>
<Checkbox <Checkbox
v-if="license?.friendly === 'Custom'" v-if="license?.friendly === 'Custom'"
v-model="nonSpdxLicense" v-model="nonSpdxLicense"
@@ -64,31 +131,18 @@
> >
License does not have a SPDX identifier License does not have a SPDX identifier
</Checkbox> </Checkbox>
<input
v-if="license?.friendly === 'Custom'"
v-model="license.short"
type="text"
maxlength="2048"
:placeholder="nonSpdxLicense ? 'License name' : 'SPDX identifier'"
:class="{
'known-error': license.short === '' && showKnownErrors,
}"
:disabled="!hasPermission"
/>
<input
v-model="licenseUrl"
type="url"
maxlength="2048"
placeholder="License URL (optional)"
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
/>
</div> </div>
</div> </div>
<div class="input-stack"> <div class="input-stack">
<button <button
type="button" type="button"
class="iconified-button brand-button" class="iconified-button brand-button"
:disabled="!hasChanges || license === null" :disabled="
!hasChanges ||
!hasPermission ||
(license.friendly === 'Custom' && (license.short === '' || licenseUrl === ''))
"
@click="saveChanges()" @click="saveChanges()"
> >
<SaveIcon /> <SaveIcon />
@@ -99,199 +153,109 @@
</div> </div>
</template> </template>
<script> <script setup lang="ts">
import Multiselect from "vue-multiselect"; import { Checkbox, DropdownSelect } from "@modrinth/ui";
import Checkbox from "~/components/ui/Checkbox"; import {
TeamMemberPermission,
builtinLicenses,
formatProjectType,
type BuiltinLicense,
type Project,
type TeamMember,
} from "@modrinth/utils";
import { computed, ref, type Ref } from "vue";
import SaveIcon from "~/assets/images/utils/save.svg?component"; import SaveIcon from "~/assets/images/utils/save.svg?component";
export default defineNuxtComponent({ const props = defineProps<{
components: { project: Project;
Multiselect, currentMember: TeamMember | undefined;
Checkbox, patchProject: (payload: Object, quiet?: boolean) => Object;
SaveIcon, }>();
},
props: {
project: {
type: Object,
default() {
return {};
},
},
currentMember: {
type: Object,
default() {
return null;
},
},
patchProject: {
type: Function,
default() {
return () => {
this.$notify({
group: "main",
title: "An error occurred",
text: "Patch project function not found",
type: "error",
});
};
},
},
},
data() {
return {
licenseUrl: "",
license: { friendly: "", short: "", requiresOnlyOrLater: false },
allowOrLater: this.project.license.id.includes("-or-later"),
nonSpdxLicense: this.project.license.id.includes("LicenseRef-"),
showKnownErrors: false,
};
},
async setup(props) {
const defaultLicenses = shallowRef([
{ friendly: "Custom", short: "" },
{
friendly: "All Rights Reserved/No License",
short: "All-Rights-Reserved",
},
{ friendly: "Apache License 2.0", short: "Apache-2.0" },
{
friendly: 'BSD 2-Clause "Simplified" License',
short: "BSD-2-Clause",
},
{
friendly: 'BSD 3-Clause "New" or "Revised" License',
short: "BSD-3-Clause",
},
{
friendly: "CC Zero (Public Domain equivalent)",
short: "CC0-1.0",
},
{ friendly: "CC-BY 4.0", short: "CC-BY-4.0" },
{
friendly: "CC-BY-SA 4.0",
short: "CC-BY-SA-4.0",
},
{
friendly: "CC-BY-NC 4.0",
short: "CC-BY-NC-4.0",
},
{
friendly: "CC-BY-NC-SA 4.0",
short: "CC-BY-NC-SA-4.0",
},
{
friendly: "CC-BY-ND 4.0",
short: "CC-BY-ND-4.0",
},
{
friendly: "CC-BY-NC-ND 4.0",
short: "CC-BY-NC-ND-4.0",
},
{
friendly: "GNU Affero General Public License v3",
short: "AGPL-3.0",
requiresOnlyOrLater: true,
},
{
friendly: "GNU Lesser General Public License v2.1",
short: "LGPL-2.1",
requiresOnlyOrLater: true,
},
{
friendly: "GNU Lesser General Public License v3",
short: "LGPL-3.0",
requiresOnlyOrLater: true,
},
{
friendly: "GNU General Public License v2",
short: "GPL-2.0",
requiresOnlyOrLater: true,
},
{
friendly: "GNU General Public License v3",
short: "GPL-3.0",
requiresOnlyOrLater: true,
},
{ friendly: "ISC License", short: "ISC" },
{ friendly: "MIT License", short: "MIT" },
{ friendly: "Mozilla Public License 2.0", short: "MPL-2.0" },
{ friendly: "zlib License", short: "Zlib" },
]);
const licenseUrl = ref(props.project.license.url); const licenseUrl = ref(props.project.license.url);
const license: Ref<{
const licenseId = props.project.license.id; friendly: string;
const trimmedLicenseId = licenseId short: string;
.replaceAll("-only", "") requiresOnlyOrLater?: boolean;
.replaceAll("-or-later", "") }> = ref({
.replaceAll("LicenseRef-", ""); friendly: "",
short: "",
const license = ref( requiresOnlyOrLater: false,
defaultLicenses.value.find((x) => x.short === trimmedLicenseId) ?? {
friendly: "Custom",
short: licenseId.replaceAll("LicenseRef-", ""),
},
);
if (licenseId === "LicenseRef-Unknown") {
license.value = {
friendly: "Unknown",
short: licenseId.replaceAll("LicenseRef-", ""),
};
}
return {
defaultLicenses,
licenseUrl,
license,
};
},
computed: {
hasPermission() {
const EDIT_DETAILS = 1 << 2;
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS;
},
licenseId() {
let id = "";
if (this.license === null) return id;
if (
(this.nonSpdxLicense && this.license.friendly === "Custom") ||
this.license.short === "All-Rights-Reserved" ||
this.license.short === "Unknown"
) {
id += "LicenseRef-";
}
id += this.license.short;
if (this.license.requiresOnlyOrLater) {
id += this.allowOrLater ? "-or-later" : "-only";
}
if (this.nonSpdxLicense && this.license.friendly === "Custom") {
id = id.replaceAll(" ", "-");
}
return id;
},
patchData() {
const data = {};
if (this.licenseId !== this.project.license.id) {
data.license_id = this.licenseId;
data.license_url = this.licenseUrl ? this.licenseUrl : null;
} else if (this.licenseUrl !== this.project.license.url) {
data.license_url = this.licenseUrl ? this.licenseUrl : null;
}
return data;
},
hasChanges() {
return Object.keys(this.patchData).length > 0;
},
},
methods: {
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData);
}
},
},
}); });
const allowOrLater = ref(props.project.license.id.includes("-or-later"));
const nonSpdxLicense = ref(props.project.license.id.includes("LicenseRef-"));
const oldLicenseId = props.project.license.id;
const trimmedLicenseId = oldLicenseId
.replaceAll("-only", "")
.replaceAll("-or-later", "")
.replaceAll("LicenseRef-", "");
license.value = builtinLicenses.find((x) => x.short === trimmedLicenseId) ?? {
friendly: "Custom",
short: oldLicenseId.replaceAll("LicenseRef-", ""),
requiresOnlyOrLater: oldLicenseId.includes("-or-later"),
};
if (oldLicenseId === "LicenseRef-Unknown") {
// Mark it as not having a license, forcing the user to select one
license.value = {
friendly: "",
short: oldLicenseId.replaceAll("LicenseRef-", ""),
requiresOnlyOrLater: false,
};
}
const hasPermission = computed(() => {
return (props.currentMember?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS;
});
const licenseId = computed(() => {
let id = "";
if (
(nonSpdxLicense && license.value.friendly === "Custom") ||
license.value.short === "All-Rights-Reserved" ||
license.value.short === "Unknown"
) {
id += "LicenseRef-";
}
id += license.value.short;
if (license.value.requiresOnlyOrLater) {
id += allowOrLater.value ? "-or-later" : "-only";
}
if (nonSpdxLicense && license.value.friendly === "Custom") {
id = id.replaceAll(" ", "-");
}
return id;
});
const patchRequestPayload = computed(() => {
const payload: {
license_id?: string;
license_url?: string | null; // null = remove url
} = {};
if (licenseId.value !== props.project.license.id) {
payload.license_id = licenseId.value;
}
if (licenseUrl.value !== props.project.license.url) {
payload.license_url = licenseUrl.value ? licenseUrl.value : null;
}
return payload;
});
const hasChanges = computed(() => {
return Object.keys(patchRequestPayload.value).length > 0;
});
function saveChanges() {
props.patchProject(patchRequestPayload.value);
}
</script> </script>

View File

@@ -640,7 +640,6 @@ import Badge from "~/components/ui/Badge.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue"; import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CopyCode from "~/components/ui/CopyCode.vue"; import CopyCode from "~/components/ui/CopyCode.vue";
import Categories from "~/components/ui/search/Categories.vue"; import Categories from "~/components/ui/search/Categories.vue";
import Chips from "~/components/ui/Chips.vue";
import Checkbox from "~/components/ui/Checkbox.vue"; import Checkbox from "~/components/ui/Checkbox.vue";
import FileInput from "~/components/ui/FileInput.vue"; import FileInput from "~/components/ui/FileInput.vue";
@@ -663,6 +662,7 @@ import Modal from "~/components/ui/Modal.vue";
import ChevronRightIcon from "~/assets/images/utils/chevron-right.svg?component"; import ChevronRightIcon from "~/assets/images/utils/chevron-right.svg?component";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue"; import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
export default defineNuxtComponent({ export default defineNuxtComponent({
components: { components: {
MarkdownEditor, MarkdownEditor,
@@ -670,7 +670,6 @@ export default defineNuxtComponent({
FileInput, FileInput,
Checkbox, Checkbox,
ChevronRightIcon, ChevronRightIcon,
Chips,
Categories, Categories,
DownloadIcon, DownloadIcon,
EditIcon, EditIcon,

View File

@@ -98,6 +98,14 @@
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')), action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
shown: !currentMember, shown: !currentMember,
}, },
{ divider: true, shown: currentMember || flags.developerMode },
{
id: 'copy-id',
action: () => {
copyToClipboard(version.id);
},
shown: currentMember || flags.developerMode,
},
{ divider: true, shown: currentMember }, { divider: true, shown: currentMember },
{ {
id: 'edit', id: 'edit',
@@ -148,6 +156,10 @@
<TrashIcon aria-hidden="true" /> <TrashIcon aria-hidden="true" />
Delete Delete
</template> </template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
</template> </template>
@@ -174,6 +186,7 @@ import {
ReportIcon, ReportIcon,
UploadIcon, UploadIcon,
InfoIcon, InfoIcon,
ClipboardCopyIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import DropArea from "~/components/ui/DropArea.vue"; import DropArea from "~/components/ui/DropArea.vue";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js"; import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";

View File

@@ -40,12 +40,7 @@
</span> </span>
<span> Whether or not the subscription should be unprovisioned on refund. </span> <span> Whether or not the subscription should be unprovisioned on refund. </span>
</label> </label>
<Toggle <Toggle id="unprovision" v-model="unprovision" />
id="unprovision"
:model-value="unprovision"
:checked="unprovision"
@update:model-value="() => (unprovision = !unprovision)"
/>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
@@ -63,50 +58,137 @@
</div> </div>
</div> </div>
</NewModal> </NewModal>
<div class="normal-page no-sidebar"> <div class="page experimental-styles-within">
<h1>{{ user.username }}'s subscriptions</h1> <div
<div class="normal-page__content"> class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
>
<div class="flex items-center gap-2">
<Avatar :src="user.avatar_url" :alt="user.username" size="32px" circle />
<h1 class="m-0 text-2xl font-extrabold">{{ user.username }}'s subscriptions</h1>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<nuxt-link :to="`/user/${user.id}`">
<UserIcon aria-hidden="true" />
User profile
<ExternalIcon class="h-4 w-4" />
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div>
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card"> <div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
<span class="font-extrabold text-contrast"> <div class="mb-4 grid grid-cols-[1fr_auto]">
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template> <div>
<template v-else-if="subscription.product.metadata.type === 'pyro'"> <span class="flex items-center gap-2 font-semibold text-contrast">
Modrinth Servers <template v-if="subscription.product.metadata.type === 'midas'">
</template> <ModrinthPlusIcon class="h-7 w-min" />
<template v-else> Unknown product </template> </template>
<template v-if="subscription.interval"> <template v-else-if="subscription.product.metadata.type === 'pyro'">
{{ subscription.interval }} <ModrinthServersIcon class="h-7 w-min" />
</template> </template>
</span> <template v-else> Unknown product </template>
<div class="mb-4 mt-2 flex items-center gap-1"> </span>
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }} <div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
<template v-if="subscription.metadata?.id"> ⋅ {{ subscription.metadata.id }}</template> {{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
</div> {{ dayjs(subscription.created).format("MMMM D, YYYY [at] h:mma") }} ({{
<div dayjs(subscription.created).fromNow()
v-for="charge in subscription.charges" }})
:key="charge.id" </div>
class="universal-card recessed flex items-center justify-between gap-4" </div>
> <div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
<div class="flex w-full items-center justify-between gap-4"> <ButtonStyled v-if="subscription.product.metadata.type === 'pyro'">
<div class="flex items-center gap-1"> <nuxt-link
<Badge :to="`/servers/manage/${subscription.metadata.id}`"
:color="charge.status === 'succeeded' ? 'green' : 'red'" target="_blank"
:type="charge.status" class="w-fit"
/> >
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
{{ charge.type }} </nuxt-link>
</ButtonStyled>
{{ $dayjs(charge.due).format("YYYY-MM-DD") }} <CopyCode :text="subscription.metadata.id" />
</div>
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span> </div>
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template> <div class="flex flex-col gap-2">
<div
v-for="(charge, index) in subscription.charges"
:key="charge.id"
class="relative overflow-clip rounded-xl bg-bg px-4 py-3"
>
<div
class="absolute bottom-0 left-0 top-0 w-1"
:class="charge.type === 'refund' ? 'bg-purple' : chargeStatuses[charge.status].color"
/>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
<div class="flex flex-col gap-2">
<span>
<span class="font-bold text-contrast">
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
<template v-else-if="charge.status === 'failed'"> Failed </template>
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
<template v-else-if="charge.status === 'processing'"> Processing </template>
<template v-else-if="charge.status === 'open'"> Upcoming </template>
<template v-else> {{ charge.status }} </template>
</span>
<span>
<template v-if="charge.type === 'refund'"> Refund </template>
<template v-else-if="charge.type === 'subscription'">
<template v-if="charge.status === 'cancelled'"> Subscription </template>
<template v-else-if="index === subscription.charges.length - 1">
Started subscription
</template>
<template v-else> Subscription renewal </template>
</template>
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
<template v-else> {{ charge.status }} </template>
</span>
<template v-if="charge.status !== 'cancelled'">
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
</template>
</span>
<span class="text-sm text-secondary">
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ dayjs(charge.due).fromNow() }}) </span>
</span>
<div
v-if="flags.developerMode"
class="flex w-full items-center gap-1 text-xs text-secondary"
>
{{ charge.status }}
{{ charge.type }}
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
{{ dayjs(charge.due).format("YYYY-MM-DD h:mma") }}
<template v-if="charge.subscription_interval">
⋅ {{ charge.subscription_interval }}
</template>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled
v-if="
charges.some((x) => x.type === 'refund' && x.parent_charge_id === charge.id)
"
>
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
</ButtonStyled>
<ButtonStyled
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
color="red"
color-fill="text"
>
<button @click="showRefundModal(charge)">
<CurrencyIcon />
Refund options
</button>
</ButtonStyled>
</div>
</div> </div>
<button
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
class="btn"
@click="showRefundModal(charge)"
>
Refund charge
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -114,11 +196,22 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui"; import { Avatar, ButtonStyled, CopyCode, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
import { formatPrice } from "@modrinth/utils"; import { formatCategory, formatPrice } from "@modrinth/utils";
import { CheckIcon, XIcon } from "@modrinth/assets"; import {
CheckIcon,
XIcon,
UserIcon,
ModrinthPlusIcon,
ServerIcon,
ExternalIcon,
CurrencyIcon,
} from "@modrinth/assets";
import dayjs from "dayjs";
import { products } from "~/generated/state.json"; import { products } from "~/generated/state.json";
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
const flags = useFeatureFlags();
const route = useRoute(); const route = useRoute();
const data = useNuxtApp(); const data = useNuxtApp();
const vintl = useVIntl(); const vintl = useVIntl();
@@ -169,7 +262,10 @@ const subscriptionCharges = computed(() => {
return subscriptions.value.map((subscription) => { return subscriptions.value.map((subscription) => {
return { return {
...subscription, ...subscription,
charges: charges.value.filter((charge) => charge.subscription_id === subscription.id), charges: charges.value
.filter((charge) => charge.subscription_id === subscription.id)
.slice()
.sort((a, b) => dayjs(b.due).diff(dayjs(a.due))),
product: products.find((product) => product: products.find((product) =>
product.prices.some((price) => price.id === subscription.price_id), product.prices.some((price) => price.id === subscription.price_id),
), ),
@@ -217,4 +313,30 @@ async function refundCharge() {
} }
refunding.value = false; refunding.value = false;
} }
const chargeStatuses = {
open: {
color: "bg-blue",
},
processing: {
color: "bg-orange",
},
succeeded: {
color: "bg-green",
},
failed: {
color: "bg-red",
},
cancelled: {
color: "bg-red",
},
};
</script> </script>
<style scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div class="normal-page no-sidebar">
<h1>User account request</h1>
<div class="normal-page__content">
<div class="card flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
User email
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="userEmail"
type="email"
maxlength="64"
:placeholder="`Enter user email...`"
autocomplete="off"
/>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="getUserFromEmail">
<MailIcon aria-hidden="true" />
Get user account
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { MailIcon } from "@modrinth/assets";
const userEmail = ref("");
async function getUserFromEmail() {
startLoading();
try {
const result = await useBaseFetch(`user_email?email=${encodeURIComponent(userEmail.value)}`, {
method: "GET",
apiVersion: 3,
});
await navigateTo(`/user/${result.username}`);
} catch (err) {
console.error(err);
addNotification({
group: "main",
title: "An error occurred",
text: err.data.description,
type: "error",
});
}
stopLoading();
}
</script>

View File

@@ -365,29 +365,29 @@
<script setup> <script setup>
import { import {
BoxIcon,
CalendarIcon, CalendarIcon,
EditIcon, EditIcon,
XIcon,
SaveIcon,
UploadIcon,
TrashIcon,
LinkIcon,
LockIcon,
GridIcon, GridIcon,
ImageIcon, ImageIcon,
ListIcon,
UpdatedIcon,
LibraryIcon, LibraryIcon,
BoxIcon, LinkIcon,
ListIcon,
LockIcon,
SaveIcon,
TrashIcon,
UpdatedIcon,
UploadIcon,
XIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { import {
PopoutMenu,
FileInput,
DropdownSelect,
Avatar, Avatar,
Button, Button,
commonMessages, commonMessages,
ConfirmModal, ConfirmModal,
DropdownSelect,
FileInput,
PopoutMenu,
} from "@modrinth/ui"; } from "@modrinth/ui";
import { isAdmin } from "@modrinth/utils"; import { isAdmin } from "@modrinth/utils";
@@ -651,7 +651,7 @@ async function saveChanges() {
method: "PATCH", method: "PATCH",
body: { body: {
name: name.value, name: name.value,
description: summary.value, description: summary.value || null,
status: visibility.value, status: visibility.value,
new_projects: newProjectIds, new_projects: newProjectIds,
}, },

View File

@@ -49,7 +49,9 @@
</div> </div>
</nuxt-link> </nuxt-link>
<nuxt-link <nuxt-link
v-for="collection in orderedCollections" v-for="collection in orderedCollections.sort(
(a, b) => new Date(b.created) - new Date(a.created),
)"
:key="collection.id" :key="collection.id"
:to="`/collection/${collection.id}`" :to="`/collection/${collection.id}`"
class="universal-card recessed collection" class="universal-card recessed collection"

View File

@@ -50,7 +50,7 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { Button } from "@modrinth/ui"; import { Button, Chips } from "@modrinth/ui";
import { HistoryIcon } from "@modrinth/assets"; import { HistoryIcon } from "@modrinth/assets";
import { import {
fetchExtraNotificationData, fetchExtraNotificationData,
@@ -58,7 +58,6 @@ import {
markAsRead, markAsRead,
} from "~/helpers/notifications.js"; } from "~/helpers/notifications.js";
import NotificationItem from "~/components/ui/NotificationItem.vue"; import NotificationItem from "~/components/ui/NotificationItem.vue";
import Chips from "~/components/ui/Chips.vue";
import CheckCheckIcon from "~/assets/images/utils/check-check.svg?component"; import CheckCheckIcon from "~/assets/images/utils/check-check.svg?component";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue"; import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import Pagination from "~/components/ui/Pagination.vue"; import Pagination from "~/components/ui/Pagination.vue";

View File

@@ -1,39 +1,95 @@
<template> <template>
<div> <div class="experimental-styles-within">
<section class="universal-card"> <section class="universal-card">
<h2 class="text-2xl">Revenue</h2> <h2 class="text-2xl">Revenue</h2>
<div v-if="userBalance.available >= minWithdraw"> <div class="grid-display">
<p> <div class="grid-display__item">
You have <div class="label">Available now</div>
<strong>{{ $formatMoney(userBalance.available) }}</strong> <div class="value">
available to withdraw. <strong>{{ $formatMoney(userBalance.pending) }}</strong> of your {{ $formatMoney(userBalance.available) }}
balance is <nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>. </div>
</p> </div>
<div class="grid-display__item">
<div class="label">
Total pending
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</div>
<div class="value">
{{ $formatMoney(userBalance.pending) }}
</div>
</div>
<div class="grid-display__item">
<h3 class="label m-0">
Available soon
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</h3>
<ul class="m-0 list-none p-0">
<li
v-for="date in availableSoonDateKeys"
:key="date"
class="flex items-center justify-between border-0 border-solid border-b-divider p-0 [&:not(:last-child)]:mb-1 [&:not(:last-child)]:border-b-[1px] [&:not(:last-child)]:pb-1"
>
<span
v-tooltip="
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1
? `Revenue period is ongoing. \nThis amount is not yet finalized.`
: null
"
:class="{
'cursor-help':
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1,
}"
class="inline-flex items-center gap-1 font-bold"
>
{{ $formatMoney(availableSoonDates[date]) }}
<template
v-if="availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1"
>
<InProgressIcon />
</template>
</span>
<span class="text-sm text-secondary">
{{ formatDate(dayjs(date)) }}
</span>
</li>
</ul>
</div>
</div> </div>
<p v-else>
You have made
<strong>{{ $formatMoney(userBalance.available) }}</strong
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
<strong>{{ $formatMoney(userBalance.pending) }}</strong> of your balance is
<nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
</p>
<div class="input-group mt-4"> <div class="input-group mt-4">
<nuxt-link <span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
v-if="userBalance.available >= minWithdraw" <nuxt-link
class="iconified-button brand-button" :aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
to="/dashboard/revenue/withdraw" :class="{ 'disabled-link': userBalance.available < minWithdraw }"
> :disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
<TransferIcon /> Withdraw :tabindex="userBalance.available < minWithdraw ? -1 : undefined"
</nuxt-link> class="iconified-button brand-button"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
</span>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers"> <NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
<HistoryIcon /> View transfer history <HistoryIcon />
View transfer history
</NuxtLink> </NuxtLink>
</div> </div>
<p> <p class="text-sm text-secondary">
By uploading projects to Modrinth and withdrawing money from your account, you agree to the By uploading projects to Modrinth and withdrawing money from your account, you agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>. For more <nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
information on how the rewards system works, see our information page information on how the rewards system works, see our information page
<nuxt-link to="/legal/cmp-info" class="text-link">here</nuxt-link>. <nuxt-link class="text-link" to="/legal/cmp-info">here</nuxt-link>.
</p> </p>
</section> </section>
<section class="universal-card"> <section class="universal-card">
@@ -46,12 +102,13 @@
{{ auth.user.payout_data.paypal_address }} {{ auth.user.payout_data.paypal_address }}
</p> </p>
<button class="btn mt-4" @click="removeAuthProvider('paypal')"> <button class="btn mt-4" @click="removeAuthProvider('paypal')">
<XIcon /> Disconnect account <XIcon />
Disconnect account
</button> </button>
</template> </template>
<template v-else> <template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p> <p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
<a class="btn mt-4" :href="`${getAuthUrl('paypal')}&token=${auth.token}`"> <a :href="`${getAuthUrl('paypal')}&token=${auth.token}`" class="btn mt-4">
<PayPalIcon /> <PayPalIcon />
Sign in with PayPal Sign in with PayPal
</a> </a>
@@ -60,7 +117,8 @@
<p> <p>
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email, Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit visit
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>. <nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
.
</p> </p>
<h3>Venmo</h3> <h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p> <p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
@@ -68,18 +126,32 @@
<input <input
id="venmo" id="venmo"
v-model="auth.user.payout_data.venmo_handle" v-model="auth.user.payout_data.venmo_handle"
autocomplete="off"
class="mt-4" class="mt-4"
type="search"
name="search" name="search"
placeholder="@example" placeholder="@example"
autocomplete="off" type="search"
/> />
<button class="btn btn-secondary" @click="updateVenmo"><SaveIcon /> Save information</button> <button class="btn btn-secondary" @click="updateVenmo">
<SaveIcon />
Save information
</button>
</section> </section>
</div> </div>
</template> </template>
<script setup> <script setup>
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from "@modrinth/assets"; import {
HistoryIcon,
InProgressIcon,
PayPalIcon,
SaveIcon,
TransferIcon,
UnknownIcon,
XIcon,
} from "@modrinth/assets";
import { formatDate } from "@modrinth/utils";
import dayjs from "dayjs";
import { computed } from "vue";
const auth = await useAuth(); const auth = await useAuth();
const minWithdraw = ref(0.01); const minWithdraw = ref(0.01);
@@ -88,6 +160,33 @@ const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
useBaseFetch(`payout/balance`, { apiVersion: 3 }), useBaseFetch(`payout/balance`, { apiVersion: 3 }),
); );
const deadlineEnding = computed(() => {
let deadline = dayjs().subtract(2, "month").endOf("month").add(60, "days");
if (deadline.isBefore(dayjs().startOf("day"))) {
deadline = dayjs().subtract(1, "month").endOf("month").add(60, "days");
}
return deadline;
});
const availableSoonDates = computed(() => {
// Get the next 3 dates from userBalance.dates that are from now to the deadline + 4 months to make sure we get all the pending ones.
const dates = Object.keys(userBalance.value.dates)
.filter((date) => {
const dateObj = dayjs(date);
return (
dateObj.isAfter(dayjs()) && dateObj.isBefore(dayjs(deadlineEnding.value).add(4, "month"))
);
})
.sort((a, b) => dayjs(a).diff(dayjs(b)));
return dates.reduce((acc, date) => {
acc[date] = userBalance.value.dates[date];
return acc;
}, {});
});
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value));
async function updateVenmo() { async function updateVenmo() {
startLoading(); startLoading();
try { try {
@@ -118,4 +217,16 @@ strong {
color: var(--color-text-dark); color: var(--color-text-dark);
font-weight: 500; font-weight: 500;
} }
.disabled-cursor-wrapper {
cursor: not-allowed;
}
.disabled-link {
pointer-events: none;
}
.grid-display {
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="markdown-body"> <div class="markdown-body">
<h1>Rewards Program Information</h1> <h1>Rewards Program Information</h1>
<p><em>Last modified: Sep 12, 2024</em></p> <p><em>Last modified: Feb 20, 2025</em></p>
<p> <p>
This page was created for transparency for how the rewards program works on Modrinth. Feel This page was created for transparency for how the rewards program works on Modrinth. Feel
free to join our Discord or email free to join our Discord or email
@@ -82,42 +82,41 @@
<p> <p>
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all
revenue is immediately available to withdraw. We pay creators as soon as we receive the money revenue is immediately available to withdraw. We pay creators as soon as we receive the money
from our ad providers, which is 60 days after the last day of each month. This table outlines from our ad providers, which is 60 days after the last day of each month.
some example dates of how NET 60 payments are made:
</p> </p>
<p>
To understand when revenue becomes available, you can use this calculator to estimate when
revenue earned on a specific date will be available for withdrawal. Please be advised that all
dates within this calculator are represented at 00:00 UTC.
</p>
<table> <table>
<thead> <tr>
<tr> <th>Timeline</th>
<th>Date</th> <th>Date</th>
<th>Payment available date</th> </tr>
</tr> <tr>
</thead> <td>Revenue earned on</td>
<tbody> <td>
<tr> <input id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
<td>January 1st</td> <noscript
<td>March 31st</td> >(JavaScript must be enabled for the date picker to function, example date: 2024-07-15)
</tr> </noscript>
<tr> </td>
<td>January 15th</td> </tr>
<td>March 31st</td> <tr>
</tr> <td>End of the month</td>
<tr> <td>{{ formatDate(endOfMonthDate) }}</td>
<td>March 3rd</td> </tr>
<td>May 30th</td> <tr>
</tr> <td>NET 60 policy applied</td>
<tr> <td>+ 60 days</td>
<td>June 30th</td> </tr>
<td>August 29th</td> <tr class="final-result">
</tr> <td>Available for withdrawal</td>
<tr> <td>{{ formatDate(withdrawalDate) }}</td>
<td>July 14th</td> </tr>
<td>September 29th</td>
</tr>
<tr>
<td>October 12th</td>
<td>December 30th</td>
</tr>
</tbody>
</table> </table>
<h3>How do I know Modrinth is being transparent about revenue?</h3> <h3>How do I know Modrinth is being transparent about revenue?</h3>
<p> <p>
@@ -127,12 +126,40 @@
revenue distribution system</a revenue distribution system</a
>. We also have an >. We also have an
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users <a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
to query exact daily revenue for the site. to query exact daily revenue for the site - so far, Modrinth has generated
<strong>{{ formatMoney(platformRevenue) }}</strong> in revenue.
</p> </p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Revenue</th>
<th>Creator Revenue (75%)</th>
<th>Modrinth's Cut (25%)</th>
</tr>
</thead>
<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>
</tr>
</tbody>
</table>
<small
>Modrinth's total revenue in the previous 5 days, for the entire dataset, use the
aforementioned
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>.</small
>
</div> </div>
</template> </template>
<script setup> <script lang="ts" setup>
import dayjs from "dayjs";
import { computed, ref } from "vue";
import { formatDate, formatMoney } from "@modrinth/utils";
const description = const description =
"Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft."; "Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.";
@@ -142,4 +169,18 @@ useSeoMeta({
ogTitle: "Rewards Program Information", ogTitle: "Rewards Program Information",
ogDescription: description, ogDescription: description,
}); });
const rawSelectedDate = ref(dayjs().format("YYYY-MM-DD"));
const selectedDate = computed(() => dayjs(rawSelectedDate.value));
const endOfMonthDate = computed(() => selectedDate.value.endOf("month"));
const withdrawalDate = computed(() => endOfMonthDate.value.add(60, "days"));
const { data: transparencyInformation } = await useAsyncData("payout/platform_revenue", () =>
useBaseFetch("payout/platform_revenue", {
apiVersion: 3,
}),
);
const platformRevenue = transparencyInformation.value.all_time;
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
</script> </script>

View File

@@ -101,7 +101,7 @@
</section> </section>
</template> </template>
<script setup> <script setup>
import Chips from "~/components/ui/Chips.vue"; import { Chips } from "@modrinth/ui";
import Avatar from "~/components/ui/Avatar.vue"; import Avatar from "~/components/ui/Avatar.vue";
import UnknownIcon from "~/assets/images/utils/unknown.svg?component"; import UnknownIcon from "~/assets/images/utils/unknown.svg?component";
import EyeIcon from "~/assets/images/utils/eye.svg?component"; import EyeIcon from "~/assets/images/utils/eye.svg?component";
@@ -164,17 +164,45 @@ const projectTypes = computed(() => {
return [...set]; return [...set];
}); });
function segmentData(data, segmentSize = 900) {
return data.reduce((acc, curr, index) => {
const segment = Math.floor(index / segmentSize);
if (!acc[segment]) {
acc[segment] = [];
}
acc[segment].push(curr);
return acc;
}, []);
}
function fetchSegmented(data, createUrl, options = {}) {
return Promise.all(segmentData(data).map((ids) => useBaseFetch(createUrl(ids), options))).then(
(results) => results.flat(),
);
}
function asEncodedJsonArray(data) {
return encodeURIComponent(JSON.stringify(data));
}
if (projects.value) { if (projects.value) {
const teamIds = projects.value.map((x) => x.team_id); const teamIds = projects.value.map((x) => x.team_id);
const organizationIds = projects.value.filter((x) => x.organization).map((x) => x.organization); const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
const url = `teams?ids=${encodeURIComponent(JSON.stringify(teamIds))}`; const [{ data: teams }, { data: orgs }] = await Promise.all([
const orgUrl = `organizations?ids=${encodeURIComponent(JSON.stringify(organizationIds))}`; useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
const { data: result } = await useAsyncData(url, () => useBaseFetch(url)); fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
const { data: orgs } = await useAsyncData(orgUrl, () => useBaseFetch(orgUrl, { apiVersion: 3 })); ),
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
}),
),
]);
if (result.value) { if (teams.value) {
members.value = result.value; members.value = teams.value;
projects.value = projects.value.map((project) => { projects.value = projects.value.map((project) => {
project.owner = members.value project.owner = members.value

View File

@@ -0,0 +1,16 @@
<template>
<div class="page experimental-styles-within">
<h1 class="m-0 text-3xl font-extrabold">Changelog</h1>
<p class="my-3">Keep up-to-date on what's new with Modrinth.</p>
<NuxtPage />
</div>
</template>
<style lang="scss" scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { getChangelog } from "@modrinth/utils";
import { ChangelogEntry, Timeline } from "@modrinth/ui";
import { ChevronLeftIcon } from "@modrinth/assets";
const route = useRoute();
const changelogEntry = computed(() =>
route.params.date
? getChangelog().find((x) => {
if (x.product === route.params.product) {
console.log("Found matching product!");
if (x.version && x.version === route.params.date) {
console.log("Found matching version!");
return x;
} else if (x.date.unix() === Number(route.params.date as string)) {
console.log("Found matching date!");
return x;
}
}
return undefined;
})
: undefined,
);
const isFirst = computed(() => changelogEntry.value?.date === getChangelog()[0].date);
if (!changelogEntry.value) {
createError({ statusCode: 404, statusMessage: "Version not found" });
}
</script>
<template>
<div v-if="changelogEntry">
<nuxt-link
:to="`/news/changelog?filter=${changelogEntry.product}`"
class="mb-4 mt-4 flex w-fit items-center gap-2 text-link"
>
<ChevronLeftIcon /> View full changelog
</nuxt-link>
<Timeline fade-out-end :fade-out-start="!isFirst" :class="{ '-mt-8': !isFirst }">
<ChangelogEntry
:entry="changelogEntry"
:first="isFirst"
show-type
:class="{ 'mt-8': !isFirst }"
/>
</Timeline>
</div>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { type Product, getChangelog } from "@modrinth/utils";
import { ChangelogEntry } from "@modrinth/ui";
import Timeline from "@modrinth/ui/src/components/base/Timeline.vue";
import NavTabs from "~/components/ui/NavTabs.vue";
const route = useRoute();
const filter = ref<Product | undefined>(undefined);
const allChangelogEntries = ref(getChangelog());
function updateFilter() {
if (route.query.filter) {
filter.value = route.query.filter as Product;
} else {
filter.value = undefined;
}
}
updateFilter();
watch(
() => route.query,
() => updateFilter(),
);
const changelogEntries = computed(() =>
allChangelogEntries.value.filter((x) => !filter.value || x.product === filter.value),
);
</script>
<template>
<NavTabs
:links="[
{
label: 'All',
href: '',
},
{
label: 'Website',
href: 'web',
},
{
label: 'Servers',
href: 'servers',
},
{
label: 'App',
href: 'app',
},
]"
query="filter"
class="mb-4"
/>
<Timeline fade-out-end>
<ChangelogEntry
v-for="(entry, index) in changelogEntries"
:key="entry.date"
:entry="entry"
:first="index === 0"
:show-type="filter === undefined"
has-link
/>
</Timeline>
</template>

View File

@@ -63,7 +63,20 @@
<h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.formNotFor) }}</h2> <h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.formNotFor) }}</h2>
<div class="text-md flex items-center gap-2 font-semibold text-contrast"> <div class="text-md flex items-center gap-2 font-semibold text-contrast">
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" /> <XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
<span>{{ formatMessage(messages.bugReports) }}</span>
<div class="flex flex-col">
<span>{{ formatMessage(messages.bugReports) }}</span>
<span v-if="itemIssueTracker" class="text-sm font-medium text-secondary">
<IntlFormatted :message-id="messages.bugReportsDescription">
<template #issues-link="{ children }">
<a class="text-link" :href="itemIssueTracker" target="_blank">
<component :is="() => children" />
<ExternalIcon aria-hidden="true" class="mb-1 ml-1 h-2.5 w-2.5" />
</a>
</template>
</IntlFormatted>
</span>
</div>
</div> </div>
<div class="text-md flex items-center gap-2 font-semibold text-contrast"> <div class="text-md flex items-center gap-2 font-semibold text-contrast">
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" /> <XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
@@ -238,6 +251,7 @@ import {
AutoLink, AutoLink,
} from "@modrinth/ui"; } from "@modrinth/ui";
import { import {
ExternalIcon,
LeftArrowIcon, LeftArrowIcon,
RightArrowIcon, RightArrowIcon,
CheckIcon, CheckIcon,
@@ -289,6 +303,7 @@ const itemIcon = ref<string | Component | undefined>();
const itemName = ref<string | undefined>(); const itemName = ref<string | undefined>();
const itemLink = ref<string | undefined>(); const itemLink = ref<string | undefined>();
const itemId = ref<string | undefined>(); const itemId = ref<string | undefined>();
const itemIssueTracker = ref<string | undefined>();
const reports = ref<Report[]>([]); const reports = ref<Report[]>([]);
const existingReport = computed(() => const existingReport = computed(() =>
@@ -319,6 +334,7 @@ async function fetchItem() {
itemName.value = undefined; itemName.value = undefined;
itemLink.value = undefined; itemLink.value = undefined;
itemId.value = undefined; itemId.value = undefined;
itemIssueTracker.value = undefined;
try { try {
if (reportItem.value === "project") { if (reportItem.value === "project") {
const project = (await useBaseFetch(`project/${reportItemID.value}`)) as Project; const project = (await useBaseFetch(`project/${reportItemID.value}`)) as Project;
@@ -328,6 +344,7 @@ async function fetchItem() {
itemName.value = project.title; itemName.value = project.title;
itemLink.value = `/project/${project.id}`; itemLink.value = `/project/${project.id}`;
itemId.value = project.id; itemId.value = project.id;
itemIssueTracker.value = project.issues_url;
} else if (reportItem.value === "version") { } else if (reportItem.value === "version") {
const version = (await useBaseFetch(`version/${reportItemID.value}`)) as Version; const version = (await useBaseFetch(`version/${reportItemID.value}`)) as Version;
currentVersion.value = version; currentVersion.value = version;
@@ -540,6 +557,10 @@ const messages = defineMessages({
id: "report.not-for.bug-reports", id: "report.not-for.bug-reports",
defaultMessage: "Bug reports", defaultMessage: "Bug reports",
}, },
bugReportsDescription: {
id: "report.not-for.bug-reports.description",
defaultMessage: "You can report bugs to their <issues-link>issue tracker</issues-link>.",
},
dmcaTakedown: { dmcaTakedown: {
id: "report.not-for.dmca", id: "report.not-for.dmca",
defaultMessage: "DMCA takedowns", defaultMessage: "DMCA takedowns",
@@ -586,7 +607,7 @@ const messages = defineMessages({
<style scoped lang="scss"> <style scoped lang="scss">
.page { .page {
padding: 0.5rem; padding: 1rem;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
max-width: 56rem; max-width: 56rem;

View File

@@ -258,7 +258,8 @@
<button <button
v-if=" v-if="
result.installed || result.installed ||
server.content.data.find((x) => x.project_id === result.project_id) || (server?.content?.data &&
server.content.data.find((x) => x.project_id === result.project_id)) ||
server.general?.project?.id === result.project_id server.general?.project?.id === result.project_id
" "
disabled disabled
@@ -376,7 +377,9 @@ async function updateServerContext() {
if (!auth.value.user) { if (!auth.value.user) {
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath)); router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
} else if (route.query.sid !== null) { } else if (route.query.sid !== null) {
server.value = await usePyroServer(route.query.sid, ["general", "content"]); server.value = await usePyroServer(route.query.sid, ["general", "content"], {
waitForModules: true,
});
} }
} }
@@ -495,8 +498,8 @@ async function serverInstall(project) {
) ?? versions[0]; ) ?? versions[0];
if (projectType.value.id === "modpack") { if (projectType.value.id === "modpack") {
await server.value.general?.reinstall( await server.value.general.reinstall(
route.query.sid, server.value.serverId,
false, false,
project.project_id, project.project_id,
version.id, version.id,
@@ -504,7 +507,7 @@ async function serverInstall(project) {
eraseDataOnInstall.value, eraseDataOnInstall.value,
); );
project.installed = true; project.installed = true;
navigateTo(`/servers/manage/${route.query.sid}/options/loader`); navigateTo(`/servers/manage/${server.value.serverId}/options/loader`);
} else if (projectType.value.id === "mod") { } else if (projectType.value.id === "mod") {
await server.value.content.install("mod", version.project_id, version.id); await server.value.content.install("mod", version.project_id, version.id);
await server.value.refresh(["content"]); await server.value.refresh(["content"]);

View File

@@ -456,9 +456,10 @@
Where are Modrinth Servers located? Can I choose a region? Where are Modrinth Servers located? Can I choose a region?
</summary> </summary>
<p class="m-0 !leading-[190%]"> <p class="m-0 !leading-[190%]">
Currently, Modrinth Servers are located in New York, Los Angeles, Seattle, and Currently, Modrinth Servers are located throughout the United States in New York,
Miami. More regions are coming soon! Your server's location is currently chosen Los Angelas, Dallas, Miami, and Spokane. More regions are coming soon! Your server's
algorithmically, but you will be able to choose a region in the future. location is currently chosen algorithmically, but you will be able to choose a
region in the future.
</p> </p>
</details> </details>
@@ -494,6 +495,97 @@
</div> </div>
</section> </section>
<section
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col gap-8">
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Server Locations
</div>
<h1 class="relative m-0 max-w-2xl text-4xl leading-[120%] md:text-7xl">
Coast-to-Coast Coverage
</h1>
</div>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-green">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-brand"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
US Coverage
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
With strategically placed servers in New York, California, Texas, Florida, and
Washington, we ensure low latency connections for players across North America.
Each location is equipped with high-performance hardware and DDoS protection.
</p>
</div>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<div class="grid size-8 place-content-center rounded-full bg-highlight-blue">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-blue"
>
<path d="M12 2a10 10 0 1 0 10 10" />
<path d="M18 13a6 6 0 0 0-6-6" />
<path d="M13 2.05a10 10 0 0 1 2 2" />
<path d="M19.5 8.5a10 10 0 0 1 2 2" />
</svg>
</div>
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
Global Expansion
</h2>
</div>
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
We're expanding to Europe and Asia-Pacific regions soon, bringing Modrinth's
seamless hosting experience worldwide. Join our Discord to stay updated on new
region launches.
</p>
</div>
</div>
</div>
<Globe />
</div>
</div>
</section>
<section <section
id="plan" id="plan"
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48" class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
@@ -511,147 +603,180 @@
? "We are currently at capacity. Please try again later." ? "We are currently at capacity. Please try again later."
: "There's a plan for everyone! Choose the one that fits your needs." : "There's a plan for everyone! Choose the one that fits your needs."
}} }}
<span class="font-bold">
Servers are currently US only, in New York, Los Angeles, Seattle, and Miami. More
regions coming soon!
</span>
</h2> </h2>
<ul class="m-0 flex w-full flex-col gap-8 p-0 lg:flex-row"> <ul class="m-0 mt-8 flex w-full flex-col gap-8 p-0 lg:flex-row">
<li class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3"> <li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
<div class="flex flex-row items-center justify-between"> <div
<h1 class="m-0">Small</h1> v-if="isSmallLowStock"
<div class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
class="grid size-8 place-content-center rounded-full bg-highlight-blue text-xs font-bold text-blue" >
> Only {{ capacityStatuses?.small?.available }} left in stock!
S </div>
<div
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
:class="{ '!rounded-t-none': isSmallLowStock }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Small</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-blue text-xs font-bold text-blue"
>
S
</div>
</div>
<p class="m-0">
Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.
</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">4 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">4 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">32 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$12<span class="text-sm font-normal text-secondary">/month</span>
</h2>
</div> </div>
<ButtonStyled color="blue" size="large">
<a
v-if="!loggedOut && isSmallAtCapacity"
:href="outOfStockUrl"
target="_blank"
class="flex items-center gap-2 !bg-highlight-blue !font-medium !text-blue"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
</a>
<button
v-else
class="!bg-highlight-blue !font-medium !text-blue"
@click="selectProduct('small')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-blue" />
</button>
</ButtonStyled>
</div> </div>
<p class="m-0">
Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.
</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">4 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">4 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">32 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$12<span class="text-sm font-normal text-secondary">/month</span>
</h2>
<ButtonStyled color="blue" size="large">
<NuxtLink
v-if="!loggedOut && isSmallAtCapacity"
:to="outOfStockUrl"
target="_blank"
class="!bg-highlight-blue !font-medium !text-blue"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
</NuxtLink>
<button
v-else
class="!bg-highlight-blue !font-medium !text-blue"
@click="selectProduct('small')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-blue" />
</button>
</ButtonStyled>
</li> </li>
<li <li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
style=" <div
background: radial-gradient( v-if="isMediumLowStock"
86.12% 101.64% at 95.97% 94.07%, class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
rgba(27, 217, 106, 0.23) 0%, >
rgba(14, 115, 56, 0.2) 100% Only {{ capacityStatuses?.medium?.available }} left in stock!
); </div>
border: 1px solid rgba(12, 107, 52, 0.55); <div
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13); style="
" background: radial-gradient(
class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3" 86.12% 101.64% at 95.97% 94.07%,
> rgba(27, 217, 106, 0.23) 0%,
<div class="flex flex-row items-center justify-between"> rgba(14, 115, 56, 0.2) 100%
<h1 class="m-0">Medium</h1> );
<div border: 1px solid rgba(12, 107, 52, 0.55);
class="grid size-8 place-content-center rounded-full bg-highlight-green text-xs font-bold text-brand" box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
> "
M class="flex w-full flex-col justify-between gap-4 rounded-2xl p-8 text-left"
:class="{ '!rounded-t-none': isMediumLowStock }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Medium</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-green text-xs font-bold text-brand"
>
M
</div>
</div>
<p class="m-0">Great for modded multiplayer and small communities.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">6 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">6 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">48 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$18<span class="text-sm font-normal text-secondary">/month</span>
</h2>
</div> </div>
<ButtonStyled color="brand" size="large">
<a
v-if="!loggedOut && isMediumAtCapacity"
:href="outOfStockUrl"
target="_blank"
class="flex items-center gap-2 !bg-highlight-green !font-medium !text-green"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
</a>
<button
v-else
class="!bg-highlight-green !font-medium !text-green"
@click="selectProduct('medium')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-green" />
</button>
</ButtonStyled>
</div> </div>
<p class="m-0">Great for modded multiplayer and small communities.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">6 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">6 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">48 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$18<span class="text-sm font-normal text-secondary">/month</span>
</h2>
<ButtonStyled color="brand" size="large">
<NuxtLink
v-if="!loggedOut && isMediumAtCapacity"
:to="outOfStockUrl"
target="_blank"
class="!bg-highlight-green !font-medium !text-green"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
</NuxtLink>
<button
v-else
class="!bg-highlight-green !font-medium !text-green"
@click="selectProduct('medium')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-green" />
</button>
</ButtonStyled>
</li> </li>
<li class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3"> <li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
<div class="flex flex-row items-center justify-between"> <div
<h1 class="m-0">Large</h1> v-if="isLargeLowStock"
<div class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
class="grid size-8 place-content-center rounded-full bg-highlight-purple text-xs font-bold text-purple" >
> Only {{ capacityStatuses?.large?.available }} left in stock!
L </div>
<div
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
:class="{ '!rounded-t-none': isLargeLowStock }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Large</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-purple text-xs font-bold text-purple"
>
L
</div>
</div>
<p class="m-0">Ideal for larger communities, modpacks, and heavy modding.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">8 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">8 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">64 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$24<span class="text-sm font-normal text-secondary">/month</span>
</h2>
</div> </div>
<ButtonStyled color="brand" size="large">
<a
v-if="!loggedOut && isLargeAtCapacity"
:href="outOfStockUrl"
target="_blank"
class="flex items-center gap-2 !bg-highlight-purple !font-medium !text-purple"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
</a>
<button
v-else
class="!bg-highlight-purple !font-medium !text-purple"
@click="selectProduct('large')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-purple" />
</button>
</ButtonStyled>
</div> </div>
<p class="m-0">Ideal for larger communities, modpacks, and heavy modding.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">8 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">8 vCPUs</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">64 GB Storage</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$24<span class="text-sm font-normal text-secondary">/month</span>
</h2>
<ButtonStyled color="brand" size="large">
<NuxtLink
v-if="!loggedOut && isLargeAtCapacity"
:to="outOfStockUrl"
target="_blank"
class="!bg-highlight-purple !font-medium !text-purple"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
</NuxtLink>
<button
v-else
class="!bg-highlight-purple !font-medium !text-purple"
@click="selectProduct('large')"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-purple" />
</button>
</ButtonStyled>
</li> </li>
</ul> </ul>
@@ -697,6 +822,7 @@ import {
} from "@modrinth/assets"; } from "@modrinth/assets";
import { products } from "~/generated/state.json"; import { products } from "~/generated/state.json";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue"; import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
import Globe from "~/components/ui/servers/Globe.vue";
const pyroProducts = products.filter((p) => p.metadata.type === "pyro"); const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
const pyroPlanProducts = pyroProducts.filter( const pyroPlanProducts = pyroProducts.filter(
@@ -746,7 +872,7 @@ const deletingSpeed = 25;
const pauseTime = 2000; const pauseTime = 2000;
const loggedOut = computed(() => !auth.value.user); const loggedOut = computed(() => !auth.value.user);
const outOfStockUrl = "https://support.modrinth.com"; const outOfStockUrl = "https://discord.modrinth.com";
const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => { const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => {
try { try {
@@ -760,9 +886,16 @@ const { data: hasServers } = await useAsyncData("ServerListCountCheck", async ()
async function fetchCapacityStatuses(customProduct = null) { async function fetchCapacityStatuses(customProduct = null) {
try { try {
const productsToCheck = customProduct?.metadata ? [customProduct] : pyroPlanProducts; const productsToCheck = customProduct?.metadata
? [customProduct]
: [
...pyroPlanProducts,
pyroProducts.reduce((min, product) =>
product.metadata.ram < min.metadata.ram ? product : min,
),
];
const capacityChecks = productsToCheck.map((product) => const capacityChecks = productsToCheck.map((product) =>
usePyroFetch("capacity", { usePyroFetch("stock", {
method: "POST", method: "POST",
body: { body: {
cpu: product.metadata.cpu, cpu: product.metadata.cpu,
@@ -774,6 +907,7 @@ async function fetchCapacityStatuses(customProduct = null) {
); );
const results = await Promise.all(capacityChecks); const results = await Promise.all(capacityChecks);
if (customProduct?.metadata) { if (customProduct?.metadata) {
return { return {
custom: results[0], custom: results[0],
@@ -783,6 +917,7 @@ async function fetchCapacityStatuses(customProduct = null) {
small: results[0], small: results[0],
medium: results[1], medium: results[1],
large: results[2], large: results[2],
custom: results[3],
}; };
} }
} catch (error) { } catch (error) {
@@ -804,6 +939,22 @@ const { data: capacityStatuses, refresh: refreshCapacity } = await useAsyncData(
const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0); const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0);
const isMediumAtCapacity = computed(() => capacityStatuses.value?.medium?.available === 0); const isMediumAtCapacity = computed(() => capacityStatuses.value?.medium?.available === 0);
const isLargeAtCapacity = computed(() => capacityStatuses.value?.large?.available === 0); const isLargeAtCapacity = computed(() => capacityStatuses.value?.large?.available === 0);
const isCustomAtCapacity = computed(() => capacityStatuses.value?.custom?.available === 0);
const isSmallLowStock = computed(() => {
const available = capacityStatuses.value?.small?.available;
return available !== undefined && available > 0 && available < 8;
});
const isMediumLowStock = computed(() => {
const available = capacityStatuses.value?.medium?.available;
return available !== undefined && available > 0 && available < 8;
});
const isLargeLowStock = computed(() => {
const available = capacityStatuses.value?.large?.available;
return available !== undefined && available > 0 && available < 8;
});
const startTyping = () => { const startTyping = () => {
const currentWord = words[currentWordIndex.value]; const currentWord = words[currentWordIndex.value];
@@ -907,7 +1058,9 @@ const selectProduct = async (product) => {
} }
await refreshCapacity(); await refreshCapacity();
if (isAtCapacity.value) { console.log(capacityStatuses.value);
if ((product === "custom" && isCustomAtCapacity.value) || isAtCapacity.value) {
addNotification({ addNotification({
group: "main", group: "main",
title: "Server Capacity Full", title: "Server Capacity Full",

View File

@@ -10,10 +10,10 @@
<div class="grid place-content-center rounded-full bg-bg-blue p-4"> <div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" /> <TransferIcon class="size-12 text-blue" />
</div> </div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Upgrading</h1> <h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
</div> </div>
<p class="text-lg text-secondary"> <p class="text-lg text-secondary">
Your server's hardware is currently being upgraded and will be back online shortly. Your server's hardware is currently being upgraded and will be back online shortly!
</p> </p>
</div> </div>
</div> </div>
@@ -47,17 +47,18 @@
<div class="grid place-content-center rounded-full bg-bg-orange p-4"> <div class="grid place-content-center rounded-full bg-bg-orange p-4">
<LockIcon class="size-12 text-orange" /> <LockIcon class="size-12 text-orange" />
</div> </div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Suspended</h1> <h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
</div> </div>
<p class="text-lg text-secondary"> <p class="text-lg text-secondary">
{{ {{
serverData.suspension_reason serverData.suspension_reason === "cancelled"
? `Your server has been suspended: ${serverData.suspension_reason}` ? "Your subscription has been cancelled."
: "Your server has been suspended." : serverData.suspension_reason
? `Your server has been suspended: ${serverData.suspension_reason}`
: "Your server has been suspended."
}} }}
<br /> <br />
This is most likely due to a billing issue. Please check your billing information and Contact Modrinth support if you believe this is an error.
contact Modrinth support if you believe this is an error.
</p> </p>
</div> </div>
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')"> <ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
@@ -66,7 +67,10 @@
</div> </div>
</div> </div>
<div <div
v-else-if="server.error && server.error.message.includes('Forbidden')" v-else-if="
server.general?.error?.error.statusCode === 403 ||
server.general?.error?.error.statusCode === 404
"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl"> <div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -82,21 +86,22 @@
this is an error, please contact Modrinth support. this is an error, please contact Modrinth support.
</p> </p>
</div> </div>
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" /> <UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')"> <ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button> <button class="mt-6 !w-full">Go back to all servers</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
<div <div
v-else-if="server.error && server.error.message.includes('Service Unavailable')" v-else-if="server.general?.error?.error.statusCode === 503"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl"> <div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center"> <div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-red p-4"> <div class="grid place-content-center rounded-full bg-bg-red p-4">
<PanelErrorIcon class="size-12 text-red" /> <UiServersIconsPanelErrorIcon class="size-12 text-red" />
</div> </div>
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1> <h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
</div> </div>
@@ -141,7 +146,7 @@
</div> </div>
</div> </div>
<div <div
v-else-if="server.error" v-else-if="server.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl"> <div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -164,7 +169,7 @@
temporary network issue. You'll be reconnected automatically. temporary network issue. You'll be reconnected automatically.
</p> </p>
</div> </div>
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" /> <UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled <ButtonStyled
:disabled="formattedTime !== '00'" :disabled="formattedTime !== '00'"
size="large" size="large"
@@ -228,7 +233,7 @@
:show-loader-label="showLoaderLabel" :show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds" :uptime-seconds="uptimeSeconds"
:linked="true" :linked="true"
class="flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex" class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/> />
</div> </div>
</div> </div>
@@ -343,7 +348,7 @@
<div <div
v-if="isReconnecting" v-if="isReconnecting"
data-pyro-server-ws-reconnecting data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast" class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
> >
<UiServersPanelSpinner /> <UiServersPanelSpinner />
Hang on, we're reconnecting to your server. Hang on, we're reconnecting to your server.
@@ -352,12 +357,17 @@
<div <div
v-if="serverData.status === 'installing'" v-if="serverData.status === 'installing'"
data-pyro-server-installing data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast" class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
> >
<UiServersPanelSpinner /> <UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
We're preparing your server, this may take a few minutes.
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
</div>
</div>
<NuxtPage <NuxtPage
:route="route" :route="route"
:is-connected="isConnected" :is-connected="isConnected"
@@ -392,10 +402,9 @@ import {
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk"; import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import { reloadNuxtApp } from "#app"; import { reloadNuxtApp, navigateTo } from "#app";
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers"; import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
import { usePyroConsole } from "~/store/console.ts"; import { usePyroConsole } from "~/store/console.ts";
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
const socket = ref<WebSocket | null>(null); const socket = ref<WebSocket | null>(null);
const isReconnecting = ref(false); const isReconnecting = ref(false);
@@ -420,21 +429,25 @@ const createdAt = ref(
const route = useNativeRoute(); const route = useNativeRoute();
const router = useRouter(); const router = useRouter();
const serverId = route.params.id as string; const serverId = route.params.id as string;
const server = await usePyroServer(serverId, [
"general", const server = await usePyroServer(serverId, ["general", "ws"]);
"content",
"backups", const loadModulesPromise = Promise.resolve().then(() => {
"network", if (server.general?.status === "suspended") {
"startup", return;
"ws", }
"fs", return server.loadModules(["content", "backups", "network", "startup", "fs"]);
]); });
provide("modulesLoaded", loadModulesPromise);
watch( watch(
() => server.error, () => [server.general?.error, server.ws?.error],
(newError) => { ([generalError, wsError]) => {
if (server.general?.status === "suspended") return; if (server.general?.status === "suspended") return;
if (newError && !newError.message.includes("Forbidden")) {
const error = generalError?.error || wsError?.error;
if (error && error.statusCode !== 403) {
startPolling(); startPolling();
} }
}, },
@@ -445,11 +458,9 @@ const errorMessage = ref("An unexpected error occurred.");
const errorLog = ref(""); const errorLog = ref("");
const errorLogFile = ref(""); const errorLogFile = ref("");
const serverData = computed(() => server.general); const serverData = computed(() => server.general);
const error = ref<Error | null>(null);
const isConnected = ref(false); const isConnected = ref(false);
const isWSAuthIncorrect = ref(false); const isWSAuthIncorrect = ref(false);
const pyroConsole = usePyroConsole(); const pyroConsole = usePyroConsole();
console.log("||||||||||||||||||||||| console", pyroConsole.output);
const cpuData = ref<number[]>([]); const cpuData = ref<number[]>([]);
const ramData = ref<number[]>([]); const ramData = ref<number[]>([]);
const isActioning = ref(false); const isActioning = ref(false);
@@ -460,6 +471,7 @@ const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>();
const uptimeSeconds = ref(0); const uptimeSeconds = ref(0);
const firstConnect = ref(true); const firstConnect = ref(true);
const copied = ref(false); const copied = ref(false);
const error = ref<Error | null>(null);
const initialConsoleMessage = [ const initialConsoleMessage = [
" __________________________________________________", " __________________________________________________",
@@ -660,24 +672,71 @@ const newLoader = ref<string | null>(null);
const newLoaderVersion = ref<string | null>(null); const newLoaderVersion = ref<string | null>(null);
const newMCVersion = ref<string | null>(null); const newMCVersion = ref<string | null>(null);
const onReinstall = (potentialArgs: any) => {
if (!serverData.value) return;
serverData.value.status = "installing";
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader;
}
if (potentialArgs?.lVersion) {
newLoaderVersion.value = potentialArgs.lVersion;
}
if (potentialArgs?.mVersion) {
newMCVersion.value = potentialArgs.mVersion;
}
error.value = null;
errorTitle.value = "Error";
errorMessage.value = "An unexpected error occurred.";
};
const handleInstallationResult = async (data: WSInstallationResultEvent) => { const handleInstallationResult = async (data: WSInstallationResultEvent) => {
switch (data.result) { switch (data.result) {
case "ok": case "ok": {
if (!serverData.value) break; if (!serverData.value) break;
serverData.value.status = "available";
if (!isFirstMount.value) { stopPolling();
await server.refresh();
} try {
await new Promise((resolve) => setTimeout(resolve, 2000));
if (server.general) {
if (newLoader.value) server.general.loader = newLoader.value; let attempts = 0;
if (newLoaderVersion.value) server.general.loader_version = newLoaderVersion.value; const maxAttempts = 3;
if (newMCVersion.value) server.general.mc_version = newMCVersion.value; let hasValidData = false;
while (!hasValidData && attempts < maxAttempts) {
attempts++;
await server.refresh(["general"], {
preserveConnection: true,
preserveInstallState: true,
});
if (serverData.value?.loader && serverData.value?.mc_version) {
hasValidData = true;
serverData.value.status = "available";
await server.refresh(["content", "startup"]);
break;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!hasValidData) {
console.error("Failed to get valid server data after installation");
}
} catch (err: unknown) {
console.error("Error refreshing data after installation:", err);
} }
newLoader.value = null;
newLoaderVersion.value = null;
newMCVersion.value = null;
error.value = null; error.value = null;
break; break;
}
case "err": { case "err": {
console.log("failed to install"); console.log("failed to install");
console.log(data); console.log(data);
@@ -706,43 +765,6 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
} }
}; };
const onReinstall = (potentialArgs: any) => {
if (!serverData.value) return;
serverData.value.status = "installing";
// serverData.value.loader = potentialArgs.loader;
// serverData.value.loader_version = potentialArgs.lVersion;
// serverData.value.mc_version = potentialArgs.mVersion;
// if (potentialArgs?.loader) {
// console.log("setting loadeconsole
// serverData.value.loader = potentialArgs.loader;
// }
// if (potentialArgs?.lVersion) {
// serverData.value.loader_version = potentialArgs.lVersion;
// }
// if (potentialArgs?.mVersion) {
// serverData.value.mc_version = potentialArgs.mVersion;
// }
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader;
}
if (potentialArgs?.lVersion) {
newLoaderVersion.value = potentialArgs.lVersion;
}
if (potentialArgs?.mVersion) {
newMCVersion.value = potentialArgs.mVersion;
}
if (!isFirstMount.value) {
server.refresh();
}
error.value = null;
errorTitle.value = "Error";
errorMessage.value = "An unexpected error occurred.";
console.log(serverData.value);
};
const updateStats = (currentStats: Stats["current"]) => { const updateStats = (currentStats: Stats["current"]) => {
isConnected.value = true; isConnected.value = true;
stats.value = { stats.value = {
@@ -762,7 +784,6 @@ const updatePowerState = (
state: ServerState, state: ServerState,
details?: { oom_killed?: boolean; exit_code?: number }, details?: { oom_killed?: boolean; exit_code?: number },
) => { ) => {
console.log("Power state:", state, details);
serverPowerState.value = state; serverPowerState.value = state;
if (state === "crashed") { if (state === "crashed") {
@@ -910,6 +931,10 @@ const cleanup = () => {
onMounted(() => { onMounted(() => {
isMounted.value = true; isMounted.value = true;
if (server.general?.status === "suspended") {
isLoading.value = false;
return;
}
if (server.error) { if (server.error) {
if (!server.error.message.includes("Forbidden")) { if (!server.error.message.includes("Forbidden")) {
startPolling(); startPolling();
@@ -959,17 +984,15 @@ onUnmounted(() => {
watch( watch(
() => serverData.value?.status, () => serverData.value?.status,
(newStatus) => { (newStatus, oldStatus) => {
if (isFirstMount.value) { if (isFirstMount.value) {
isFirstMount.value = false; isFirstMount.value = false;
return; return;
} }
if (newStatus === "installing") { if (newStatus === "installing" && oldStatus !== "installing") {
countdown.value = 15;
startPolling(); startPolling();
} else {
stopPolling();
server.refresh();
} }
}, },
); );
@@ -979,7 +1002,7 @@ definePageMeta({
}); });
</script> </script>
<style scoped> <style>
@keyframes server-action-buttons-anim { @keyframes server-action-buttons-anim {
0% { 0% {
opacity: 0; opacity: 0;
@@ -996,7 +1019,16 @@ definePageMeta({
} }
.mobile-blurred-servericon::before { .mobile-blurred-servericon::before {
@apply absolute left-0 top-0 block h-36 w-full bg-cover bg-center bg-no-repeat blur-2xl sm:hidden; position: absolute;
left: 0;
top: 0;
display: block;
height: 9rem;
width: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: blur(1rem);
content: ""; content: "";
background-image: linear-gradient( background-image: linear-gradient(
to bottom, to bottom,
@@ -1005,4 +1037,10 @@ definePageMeta({
), ),
var(--server-bg-image); var(--server-bg-image);
} }
@media screen and (min-width: 640px) {
.mobile-blurred-servericon::before {
display: none;
}
}
</style> </style>

View File

@@ -1,6 +1,30 @@
<template> <template>
<div class="contents"> <div class="contents">
<div v-if="data" class="contents"> <div
v-if="server.backups?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="contents">
<LazyUiServersBackupCreateModal <LazyUiServersBackupCreateModal
ref="createBackupModal" ref="createBackupModal"
:server="server" :server="server"
@@ -53,7 +77,10 @@
</div> </div>
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row"> <div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
<ButtonStyled type="standard"> <ButtonStyled type="standard">
<button @click="showbackupSettingsModal"> <button
:disabled="server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" /> <SettingsIcon class="h-5 w-5" />
Auto backups Auto backups
</button> </button>
@@ -63,13 +90,16 @@
v-tooltip=" v-tooltip="
isServerRunning && !userPreferences.backupWhileRunning isServerRunning && !userPreferences.backupWhileRunning
? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.' ? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.'
: '' : server.general?.status === 'installing'
? 'Cannot create backups while server is being installed'
: ''
" "
class="w-full sm:w-fit" class="w-full sm:w-fit"
:disabled=" :disabled="
(isServerRunning && !userPreferences.backupWhileRunning) || (isServerRunning && !userPreferences.backupWhileRunning) ||
data.used_backup_quota >= data.backup_quota || data.used_backup_quota >= data.backup_quota ||
backups.some((backup) => backup.ongoing) backups.some((backup) => backup.ongoing) ||
server.general?.status === 'installing'
" "
@click="showCreateModel" @click="showCreateModel"
> >
@@ -90,108 +120,111 @@
automatically refresh when the backup is complete. automatically refresh when the backup is complete.
</div> </div>
<li <div class="flex w-full flex-col gap-2">
v-for="(backup, index) in backups" <li
:key="backup.id" v-for="(backup, index) in backups"
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-4 shadow-md" :key="backup.id"
> class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-2 shadow-md"
<div class="flex flex-col gap-4"> >
<div class="flex items-center justify-between"> <div class="flex flex-col gap-4">
<div class="flex min-w-0 flex-row items-center gap-4"> <div class="flex items-center justify-between">
<div <div class="flex min-w-0 flex-row items-center gap-4">
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm" <div
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'" class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
> :class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
<UiServersIconsLoadingIcon >
v-if="backup.ongoing" <UiServersIconsLoadingIcon
v-tooltip="'Backup in progress'" v-if="backup.ongoing"
class="size-6 animate-spin" v-tooltip="'Backup in progress'"
/> class="size-6 animate-spin"
<LockIcon v-else-if="backup.locked" class="size-8" /> />
<BoxIcon v-else class="size-8" /> <LockIcon v-else-if="backup.locked" class="size-8" />
</div> <BoxIcon v-else class="size-8" />
<div class="flex min-w-0 flex-col gap-2"> </div>
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center"> <div class="flex min-w-0 flex-col gap-2">
<div class="max-w-full truncate text-xl font-bold text-contrast"> <div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
{{ backup.name }} <div class="max-w-full truncate font-bold text-contrast">
</div> {{ backup.name }}
</div>
<div <div
v-if="index == 0" v-if="index == 0"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex" class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
> >
<CheckIcon class="size-4" /> Latest <CheckIcon class="size-4" /> Latest
</div>
</div>
<div class="flex items-center gap-1 text-xs">
<CalendarIcon class="size-4" />
{{
new Date(backup.created_at).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}}
</div> </div>
</div> </div>
<div class="flex items-center gap-2 text-sm font-semibold">
<CalendarIcon class="size-4" />
{{
new Date(backup.created_at).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}}
</div>
</div> </div>
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
<UiServersTeleportOverflowMenu
direction="left"
position="bottom"
class="bg-transparent"
:disabled="backups.some((b) => b.ongoing)"
:options="[
{
id: 'rename',
action: () => {
renameBackupName = backup.name;
currentBackup = backup.id;
renameBackupModal?.show();
},
},
{
id: 'restore',
action: () => {
currentBackup = backup.id;
restoreBackupModal?.show();
},
},
{ id: 'download', action: () => initiateDownload(backup.id) },
{
id: 'lock',
action: () => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
},
},
{
id: 'delete',
action: () => {
currentBackup = backup.id;
deleteBackupModal?.show();
},
color: 'red',
},
]"
>
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #rename> <EditIcon /> Rename </template>
<template #restore> <ClipboardCopyIcon /> Restore </template>
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
<template v-else #lock> <LockIcon /> Lock </template>
<template #download> <DownloadIcon /> Download </template>
<template #delete> <TrashIcon /> Delete </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div> </div>
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
<UiServersTeleportOverflowMenu
direction="left"
position="bottom"
class="bg-transparent"
:options="[
{
id: 'rename',
action: () => {
renameBackupName = backup.name;
currentBackup = backup.id;
renameBackupModal?.show();
},
},
{
id: 'restore',
action: () => {
currentBackup = backup.id;
restoreBackupModal?.show();
},
},
{ id: 'download', action: () => initiateDownload(backup.id) },
{
id: 'lock',
action: () => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
},
},
{
id: 'delete',
action: () => {
currentBackup = backup.id;
deleteBackupModal?.show();
},
color: 'red',
},
]"
>
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #rename> <EditIcon /> Rename </template>
<template #restore> <ClipboardCopyIcon /> Restore </template>
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
<template v-else #lock> <LockIcon /> Lock </template>
<template #download> <DownloadIcon /> Download </template>
<template #delete> <TrashIcon /> Delete </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div> </div>
</div> </li>
</li> </div>
</ul> </ul>
<div <div
@@ -232,6 +265,7 @@ import {
BoxIcon, BoxIcon,
LockIcon, LockIcon,
LockOpenIcon, LockOpenIcon,
IssuesIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
@@ -288,33 +322,37 @@ const showbackupSettingsModal = () => {
backupSettingsModal.value?.show(); backupSettingsModal.value?.show();
}; };
const handleBackupCreated = (payload: { success: boolean; message: string }) => { const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
}; };
const handleBackupRenamed = (payload: { success: boolean; message: string }) => { const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
}; };
const handleBackupRestored = (payload: { success: boolean; message: string }) => { const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
}; };
const handleBackupDeleted = (payload: { success: boolean; message: string }) => { const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
@@ -378,8 +416,8 @@ onMounted(() => {
} }
if (hasOngoingBackups) { if (hasOngoingBackups) {
refreshInterval.value = setInterval(() => { refreshInterval.value = setInterval(async () => {
props.server.refresh(["backups"]); await props.server.refresh(["backups"]);
}, 10000); }, 10000);
} }
}); });

View File

@@ -1,59 +1,39 @@
<template> <template>
<NewModal ref="modModal" header="Editing mod version"> <UiServersContentVersionEditModal
<div> v-if="!invalidModal"
<div class="mb-4 flex flex-col gap-4"> ref="versionEditModal"
<div class="inline-flex flex-wrap items-center"> :type="type"
You're changing the version of :mod-pack="Boolean(props.server.general?.upstream)"
<div class="inline-flex flex-wrap items-center gap-1 text-nowrap pl-2"> :game-version="props.server.general?.mc_version ?? ''"
<UiAvatar :loader="props.server.general?.loader?.toLowerCase() ?? ''"
:src="currentMod?.icon_url" :server-id="props.server.serverId"
size="24px" @change-version="changeModVersion($event)"
class="inline-block" />
alt="Server Icon"
/> <div
<strong>{{ currentMod?.name + "." }}</strong> v-if="server.content?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div> </div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
</div> </div>
<div> <p class="text-lg text-secondary">
<div v-if="props.server.general?.upstream" class="flex gap-2"> We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
<InfoIcon class="hidden sm:block" /> <span class="break-all font-mono">{{ JSON.stringify(server.content.error) }}</span>
<span class="text-sm text-secondary"> </p>
Changing the mod version may cause unexpected issues. Because your server was created <ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
from a modpack, it is recommended to use the modpack's version of the mod. <button class="mt-6 !w-full">Retry</button>
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<UiServersTeleportDropdownMenu
v-model="currentVersion"
name="Project"
:options="currentVersions"
placeholder="Select project..."
class="!w-full"
:display-name="
(version) => (typeof version === 'object' ? version?.version_number : version)
"
/>
</div>
<div class="mt-4 flex flex-row items-center gap-4">
<ButtonStyled color="brand">
<button :disabled="currentMod.changing" @click="changeModVersion">
<PlusIcon />
Install
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modModal.value.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
</NewModal> </div>
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col"> <div v-else-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel /> <div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
<div class="relative flex h-full w-full flex-col"> <div class="relative flex h-full w-full flex-col">
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3"> <div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
@@ -123,7 +103,7 @@
class="rounded-xl bg-bg-raised" class="rounded-xl bg-bg-raised"
:margin-bottom="16" :margin-bottom="16"
:file-type="type" :file-type="type"
:current-path="'/mods'" :current-path="`/${type.toLocaleLowerCase()}s`"
:fs="props.server.fs" :fs="props.server.fs"
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')" :accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
@upload-complete="() => props.server.refresh(['content'])" @upload-complete="() => props.server.refresh(['content'])"
@@ -149,7 +129,7 @@
:to=" :to="
mod.project_id mod.project_id
? `/project/${mod.project_id}/version/${mod.version_id}` ? `/project/${mod.project_id}/version/${mod.version_id}`
: `files?path=mods` : `files?path=${type.toLocaleLowerCase()}s`
" "
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2" class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
draggable="false" draggable="false"
@@ -162,9 +142,7 @@
/> />
<div class="flex min-w-0 flex-col gap-1"> <div class="flex min-w-0 flex-col gap-1">
<span class="text-md flex min-w-0 items-center gap-2 font-bold"> <span class="text-md flex min-w-0 items-center gap-2 font-bold">
<span class="truncate text-contrast">{{ <span class="truncate text-contrast">{{ friendlyModName(mod) }}</span>
mod.name || mod.filename.replace(".disabled", "")
}}</span>
<span <span
v-if="mod.disabled" v-if="mod.disabled"
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block" class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
@@ -174,19 +152,21 @@
<div class="min-w-0 text-xs text-secondary"> <div class="min-w-0 text-xs text-secondary">
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span> <span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
<span class="block font-semibold sm:hidden"> <span class="block font-semibold sm:hidden">
{{ mod.version_number || "External mod" }} {{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
</span> </span>
</div> </div>
</div> </div>
</NuxtLink> </NuxtLink>
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex"> <div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
<div class="truncate font-semibold text-contrast"> <div class="truncate font-semibold text-contrast">
<span v-tooltip="'Mod version'">{{ <span v-tooltip="`${type} version`">{{
mod.version_number || "External mod" mod.version_number || `External ${type.toLocaleLowerCase()}`
}}</span> }}</span>
</div> </div>
<div class="truncate"> <div class="truncate">
<span v-tooltip="'Mod file name'">{{ mod.filename }}</span> <span v-tooltip="`${type} file name`">
{{ mod.filename }}
</span>
</div> </div>
</div> </div>
<div <div
@@ -194,7 +174,7 @@
> >
<ButtonStyled color="red" type="transparent"> <ButtonStyled color="red" type="transparent">
<button <button
v-tooltip="'Delete mod'" v-tooltip="`Delete ${type.toLocaleLowerCase()}`"
:disabled="mod.changing" :disabled="mod.changing"
class="!hidden sm:!block" class="!hidden sm:!block"
@click="removeMod(mod)" @click="removeMod(mod)"
@@ -205,14 +185,16 @@
<ButtonStyled type="transparent"> <ButtonStyled type="transparent">
<button <button
v-tooltip=" v-tooltip="
mod.project_id ? 'Edit mod version' : 'External mods cannot be edited' mod.project_id
? `Edit ${type.toLocaleLowerCase()} version`
: `External ${type.toLocaleLowerCase()}s cannot be edited`
" "
:disabled="mod.changing || !mod.project_id" :disabled="mod.changing || !mod.project_id"
class="!hidden sm:!block" class="!hidden sm:!block"
@click="beginChangeModVersion(mod)" @click="showVersionModal(mod)"
> >
<template v-if="mod.changing"> <template v-if="mod.changing">
<UiServersIconsLoadingIcon /> <UiServersIconsLoadingIcon class="animate-spin" />
</template> </template>
<template v-else> <template v-else>
<EditIcon /> <EditIcon />
@@ -232,7 +214,7 @@
:options="[ :options="[
{ {
id: 'edit', id: 'edit',
action: () => beginChangeModVersion(mod), action: () => showVersionModal(mod),
shown: !!(mod.project_id && !mod.changing), shown: !!(mod.project_id && !mod.changing),
}, },
{ {
@@ -357,16 +339,15 @@ import {
PackageClosedIcon, PackageClosedIcon,
FilterIcon, FilterIcon,
DropdownIcon, DropdownIcon,
InfoIcon,
XIcon,
PlusIcon, PlusIcon,
MoreVerticalIcon, MoreVerticalIcon,
CompassIcon, CompassIcon,
WrenchIcon, WrenchIcon,
ListIcon, ListIcon,
FileIcon, FileIcon,
IssuesIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted, onUnmounted } from "vue"; import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue"; import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue"; import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
@@ -401,6 +382,64 @@ const filterMethod = ref("all");
const uploadDropdownRef = ref(); const uploadDropdownRef = ref();
const versionEditModal = ref();
const currentEditMod = ref<ContentItem | null>(null);
const invalidModal = computed(
() => !props.server.general?.mc_version || !props.server.general?.loader,
);
async function changeModVersion(event: string) {
const mod = currentEditMod.value;
if (mod) mod.changing = true;
try {
versionEditModal.value.hide();
// This will be used instead once backend implementation is done
// await props.server.content?.reinstall(
// `/${type.value.toLowerCase()}s/${event.fileName}`,
// currentMod.value.project_id,
// currentVersion.value.id,
// );
await props.server.content?.install(
type.value.toLowerCase() as "mod" | "plugin",
mod?.project_id || "",
event,
);
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod?.filename}`);
await props.server.refresh(["general", "content"]);
} catch (error) {
const errmsg = `Error changing mod version: ${error}`;
console.error(errmsg);
addNotification({
text: errmsg,
type: "error",
});
return;
}
if (mod) mod.changing = false;
}
function showVersionModal(mod: ContentItem) {
if (invalidModal.value || !mod?.project_id || !mod?.filename) {
const errmsg = invalidModal.value
? "Data required for changing mod version was not found."
: `${!mod?.project_id ? "No mod project ID found" : "No mod filename found"} for ${friendlyModName(mod!)}`;
console.error(errmsg);
addNotification({
text: errmsg,
type: "error",
});
return;
}
currentEditMod.value = mod;
versionEditModal.value.show(mod);
}
const handleDroppedFiles = (files: File[]) => { const handleDroppedFiles = (files: File[]) => {
files.forEach((file) => { files.forEach((file) => {
uploadDropdownRef.value?.uploadFile(file); uploadDropdownRef.value?.uploadFile(file);
@@ -529,17 +568,30 @@ const debouncedSearch = debounce(() => {
} }
}, 300); }, 300);
function friendlyModName(mod: ContentItem) {
if (mod.name) return mod.name;
// remove .disabled if at the end of the filename
let cleanName = mod.filename.endsWith(".disabled") ? mod.filename.slice(0, -9) : mod.filename;
// remove everything after the last dot
const lastDotIndex = cleanName.lastIndexOf(".");
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex);
return cleanName;
}
async function toggleMod(mod: ContentItem) { async function toggleMod(mod: ContentItem) {
mod.changing = true; mod.changing = true;
const originalFilename = mod.filename; const originalFilename = mod.filename;
try { try {
const newFilename = mod.filename.endsWith(".disabled") const newFilename = mod.filename.endsWith(".disabled")
? mod.filename.replace(".disabled", "") ? mod.filename.slice(0, -9)
: `${mod.filename}.disabled`; : `${mod.filename}.disabled`;
const sourcePath = `/mods/${mod.filename}`; const folder = `${type.value.toLocaleLowerCase()}s`;
const destinationPath = `/mods/${newFilename}`; const sourcePath = `/${folder}/${mod.filename}`;
const destinationPath = `/${folder}/${newFilename}`;
mod.disabled = newFilename.endsWith(".disabled"); mod.disabled = newFilename.endsWith(".disabled");
mod.filename = newFilename; mod.filename = newFilename;
@@ -553,7 +605,7 @@ async function toggleMod(mod: ContentItem) {
console.error("Error toggling mod:", error); console.error("Error toggling mod:", error);
addNotification({ addNotification({
text: `Something went wrong toggling ${mod.name || mod.filename.replace(".disabled", "")}`, text: `Something went wrong toggling ${friendlyModName(mod)}`,
type: "error", type: "error",
}); });
} }
@@ -565,10 +617,7 @@ async function removeMod(mod: ContentItem) {
mod.changing = true; mod.changing = true;
try { try {
await props.server.content?.remove( await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod.filename}`);
type.value as "Mod" | "Plugin",
`/${type.value.toLowerCase()}s/${mod.filename}`,
);
await props.server.refresh(["general", "content"]); await props.server.refresh(["general", "content"]);
} catch (error) { } catch (error) {
console.error("Error removing mod:", error); console.error("Error removing mod:", error);
@@ -582,41 +631,6 @@ async function removeMod(mod: ContentItem) {
mod.changing = false; mod.changing = false;
} }
const modModal = ref();
const currentMod = ref();
const currentVersions = ref();
const currentVersion = ref();
async function beginChangeModVersion(mod: Mod) {
currentMod.value = mod;
currentVersions.value = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
currentVersions.value = currentVersions.value.filter((version: any) =>
version.loaders.includes(props.server.general?.loader?.toLowerCase()),
);
currentVersion.value = currentVersions.value.find(
(version: any) => version.id === mod.version_id,
);
modModal.value.show();
}
async function changeModVersion() {
currentMod.value.changing = true;
try {
modModal.value.hide();
await props.server.content?.reinstall(
type.value,
currentMod.value.version_id,
currentVersion.value.id,
);
await props.server.refresh(["general", "content"]);
} catch (error) {
console.error("Error changing mod version:", error);
}
currentMod.value.changing = false;
}
const hasMods = computed(() => { const hasMods = computed(() => {
return localMods.value?.length > 0; return localMods.value?.length > 0;
}); });
@@ -646,9 +660,7 @@ const filteredMods = computed(() => {
})(); })();
return statusFilteredMods.sort((a, b) => { return statusFilteredMods.sort((a, b) => {
const aName = a.name || a.filename.replace(".disabled", ""); return friendlyModName(a).localeCompare(friendlyModName(b));
const bName = b.name || b.filename.replace(".disabled", "");
return aName.localeCompare(bName);
}); });
}); });
</script> </script>

View File

@@ -34,13 +34,18 @@
<UiServersFilesBrowseNavbar <UiServersFilesBrowseNavbar
:breadcrumb-segments="breadcrumbSegments" :breadcrumb-segments="breadcrumbSegments"
:search-query="searchQuery" :search-query="searchQuery"
:sort-method="sortMethod" :current-filter="viewFilter"
@navigate="navigateToSegment" @navigate="navigateToSegment"
@sort="sortFiles"
@create="showCreateModal" @create="showCreateModal"
@upload="initiateFileUpload" @upload="initiateFileUpload"
@filter="handleFilter"
@update:search-query="searchQuery = $event" @update:search-query="searchQuery = $event"
/> />
<UiServersFilesLabelBar
:sort-field="sortMethod"
:sort-desc="sortDesc"
@sort="handleSort"
/>
<FilesUploadDropdown <FilesUploadDropdown
v-if="props.server.fs" v-if="props.server.fs"
ref="uploadDropdownRef" ref="uploadDropdownRef"
@@ -94,7 +99,6 @@
</div> </div>
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl"> <div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
<UiServersFilesLabelBar />
<UiServersFileVirtualList <UiServersFileVirtualList
:items="filteredItems" :items="filteredItems"
@delete="showDeleteModal" @delete="showDeleteModal"
@@ -185,6 +189,8 @@ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>(); }>();
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -196,7 +202,8 @@ const operationHistory = ref<Operation[]>([]);
const redoStack = ref<Operation[]>([]); const redoStack = ref<Operation[]>([]);
const searchQuery = ref(""); const searchQuery = ref("");
const sortMethod = ref("default"); const sortMethod = ref("name");
const sortDesc = ref(false);
const maxResults = 100; const maxResults = 100;
const currentPage = ref(1); const currentPage = ref(1);
@@ -227,11 +234,21 @@ const uploadDropdownRef = ref();
const data = computed(() => props.server.general); const data = computed(() => props.server.general);
const viewFilter = ref("all");
const handleFilter = (type: string) => {
viewFilter.value = type;
sortMethod.value = "name";
sortDesc.value = false;
};
useHead({ useHead({
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`), title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
}); });
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => { const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
await modulesLoaded;
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value; const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
try { try {
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults); const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
@@ -567,6 +584,51 @@ const applyDefaultSort = (items: DirectoryItem[]) => {
}); });
}; };
const handleSort = (field: string) => {
if (sortMethod.value === field) {
sortDesc.value = !sortDesc.value;
} else {
sortMethod.value = field;
sortDesc.value = false;
}
};
const applySort = (items: DirectoryItem[]) => {
let result = [...items];
switch (viewFilter.value) {
case "filesOnly":
result = result.filter((item) => item.type !== "directory");
break;
case "foldersOnly":
result = result.filter((item) => item.type === "directory");
break;
}
const compareItems = (a: DirectoryItem, b: DirectoryItem) => {
if (viewFilter.value === "all") {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
}
switch (sortMethod.value) {
case "modified":
return sortDesc.value
? new Date(a.modified).getTime() - new Date(b.modified).getTime()
: new Date(b.modified).getTime() - new Date(a.modified).getTime();
case "created":
return sortDesc.value
? new Date(a.created).getTime() - new Date(b.created).getTime()
: new Date(b.created).getTime() - new Date(a.created).getTime();
default:
return sortDesc.value ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name);
}
};
result.sort(compareItems);
return result;
};
const filteredItems = computed(() => { const filteredItems = computed(() => {
let result = [...items.value]; let result = [...items.value];
@@ -575,24 +637,7 @@ const filteredItems = computed(() => {
result = result.filter((item) => item.name.toLowerCase().includes(query)); result = result.filter((item) => item.name.toLowerCase().includes(query));
} }
switch (sortMethod.value) { return applySort(result);
case "modified":
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
break;
case "created":
result.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
break;
case "filesOnly":
result = result.filter((item) => item.type !== "directory");
break;
case "foldersOnly":
result = result.filter((item) => item.type === "directory");
break;
default:
result = applyDefaultSort(result);
}
return result;
}); });
const { reset } = useInfiniteScroll( const { reset } = useInfiniteScroll(
@@ -656,10 +701,6 @@ const onAnywhereClicked = (e: MouseEvent) => {
} }
}; };
const sortFiles = (method: string) => {
sortMethod.value = method;
};
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp"]; const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp"];
const editFile = async (item: { name: string; type: string; path: string }) => { const editFile = async (item: { name: string; type: string; path: string }) => {
@@ -682,7 +723,22 @@ const editFile = async (item: { name: string; type: string; path: string }) => {
} }
}; };
const initializeFileEdit = async () => {
if (!route.query.editing || !props.server.fs) return;
const filePath = route.query.editing as string;
await editFile({
name: filePath.split("/").pop() || "",
type: "file",
path: filePath,
});
};
onMounted(async () => { onMounted(async () => {
await modulesLoaded;
await initializeFileEdit();
await import("ace-builds"); await import("ace-builds");
await import("ace-builds/src-noconflict/mode-json"); await import("ace-builds/src-noconflict/mode-json");
await import("ace-builds/src-noconflict/mode-yaml"); await import("ace-builds/src-noconflict/mode-yaml");
@@ -717,7 +773,9 @@ watch(
async (newQuery) => { async (newQuery) => {
currentPage.value = 1; currentPage.value = 1;
searchQuery.value = ""; searchQuery.value = "";
sortMethod.value = "default"; viewFilter.value = "all";
sortMethod.value = "name";
sortDesc.value = false;
currentPath.value = Array.isArray(newQuery.path) currentPath.value = Array.isArray(newQuery.path)
? newQuery.path.join("") ? newQuery.path.join("")

View File

@@ -80,14 +80,19 @@
<div class="flex flex-col-reverse gap-6 md:flex-col"> <div class="flex flex-col-reverse gap-6 md:flex-col">
<UiServersServerStats :data="stats" /> <UiServersServerStats :data="stats" />
<div <div
class="relative flex h-[600px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8" class="relative flex h-[700px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2> <h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
<UiServersPanelServerStatus :state="serverPowerState" /> <UiServersPanelServerStatus :state="serverPowerState" />
</div> </div>
</div> </div>
<!-- <div class="flex flex-row items-center gap-2 text-sm font-medium">
<InfoIcon class="hidden sm:block" />
Click and drag to select lines, then CMD+C to copy
</div> -->
<UiServersPanelTerminal :full-screen="fullScreen"> <UiServersPanelTerminal :full-screen="fullScreen">
<div class="relative w-full px-4 pt-4"> <div class="relative w-full px-4 pt-4">
<ul <ul
@@ -164,7 +169,7 @@
</div> </div>
</div> </div>
</div> </div>
<UiServersPanelOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" /> <UiServersOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="isWsAuthIncorrect" class="flex flex-col"> <div v-else-if="isWsAuthIncorrect" class="flex flex-col">
<h2>Could not connect to the server.</h2> <h2>Could not connect to the server.</h2>
<p> <p>
@@ -239,19 +244,31 @@ interface ErrorData {
const inspectingError = ref<ErrorData | null>(null); const inspectingError = ref<ErrorData | null>(null);
const inspectError = async () => { const inspectError = async () => {
const log = await props.server.fs?.downloadFile("logs/latest.log"); try {
// @ts-ignore const log = await props.server.fs?.downloadFile("logs/latest.log");
const analysis = (await $fetch(`https://api.mclo.gs/1/analyse`, { if (!log) return;
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
content: log,
}),
})) as ErrorData;
inspectingError.value = analysis; // @ts-ignore
const response = await $fetch(`https://api.mclo.gs/1/analyse`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
content: log,
}),
});
// @ts-ignore
if (response && response.analysis && Array.isArray(response.analysis.problems)) {
inspectingError.value = response as ErrorData;
} else {
inspectingError.value = null;
}
} catch (error) {
console.error("Failed to analyze logs:", error);
inspectingError.value = null;
}
}; };
const clearError = () => { const clearError = () => {
@@ -261,7 +278,7 @@ const clearError = () => {
watch( watch(
() => props.serverPowerState, () => props.serverPowerState,
(newVal) => { (newVal) => {
if (newVal === "crashed") { if (newVal === "crashed" && !props.powerStateDetails?.oom_killed) {
inspectError(); inspectError();
} else { } else {
clearError(); clearError();
@@ -269,7 +286,7 @@ watch(
}, },
); );
if (props.serverPowerState === "crashed") { if (props.serverPowerState === "crashed" && !props.powerStateDetails?.oom_killed) {
inspectError(); inspectError();
} }

View File

@@ -5,7 +5,7 @@
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
<label for="server-name-field" class="flex flex-col gap-2"> <label for="server-name-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server name</span> <span class="text-lg font-bold text-contrast">Server name</span>
<span> Change your server's name. This name is only visible on Modrinth.</span> <span> This name is only visible on Modrinth.</span>
</label> </label>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<input <input
@@ -64,10 +64,7 @@
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
<label for="server-icon-field" class="flex flex-col gap-2"> <label for="server-icon-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server icon</span> <span class="text-lg font-bold text-contrast">Server icon</span>
<span> <span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
Change your server's icon. Changes will be visible on the Minecraft server list and on
Modrinth.
</span>
</label> </label>
<div class="flex gap-4"> <div class="flex gap-4">
<div <div
@@ -91,20 +88,7 @@
> >
<EditIcon class="h-8 w-8 text-contrast" /> <EditIcon class="h-8 w-8 text-contrast" />
</div> </div>
<img <UiServersServerIcon :image="icon" />
v-if="icon"
no-shadow
alt="Server Icon"
class="h-[6rem] w-[6rem] rounded-xl"
:src="icon"
/>
<img
v-else
no-shadow
alt="Server Icon"
class="h-[6rem] w-[6rem] rounded-xl"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
</div> </div>
<ButtonStyled> <ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon"> <button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
@@ -116,7 +100,7 @@
</div> </div>
</div> </div>
</div> </div>
<UiServersPyroLoading v-else /> <div v-else />
<UiServersSaveBanner <UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName" :is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server" :server="props.server"
@@ -234,67 +218,106 @@ const resetGeneral = () => {
const uploadFile = async (e: Event) => { const uploadFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]; const file = (e.target as HTMLInputElement).files?.[0];
// down scale the image to 64x64 if (!file) {
addNotification({
group: "serverOptions",
type: "error",
title: "No file selected",
text: "Please select a file to upload.",
});
return;
}
const scaledFile = await new Promise<File>((resolve, reject) => { const scaledFile = await new Promise<File>((resolve, reject) => {
if (!file) {
addNotification({
group: "serverOptions",
type: "error",
title: "No file selected",
text: "Please select a file to upload.",
});
reject(new Error("No file selected"));
return;
}
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const img = new Image(); const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => { img.onload = () => {
canvas.width = 64; canvas.width = 64;
canvas.height = 64; canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64); ctx?.drawImage(img, 0, 0, 64, 64);
// turn the downscaled image back to a png file
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob) { if (blob) {
const data = new File([blob], "server-icon.png", { type: "image/png" }); resolve(new File([blob], "server-icon.png", { type: "image/png" }));
resolve(data);
} else { } else {
reject(new Error("Canvas toBlob failed")); reject(new Error("Canvas toBlob failed"));
} }
}, "image/png"); }, "image/png");
URL.revokeObjectURL(img.src);
}; };
img.onerror = reject; img.onerror = reject;
img.src = URL.createObjectURL(file);
}); });
if (!file) return;
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
}
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
await props.server.fs?.uploadFile("/server-icon-original.png", file);
await props.server.refresh();
addNotification({ try {
group: "serverOptions", if (data.value?.image) {
type: "success", await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
title: "Server icon updated", await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
text: "Your server icon was successfully changed.", }
});
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
await props.server.fs?.uploadFile("/server-icon-original.png", file);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
useState(`server-icon-${props.server.serverId}`).value = dataURL;
if (data.value) data.value.image = dataURL;
resolve();
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
addNotification({
group: "serverOptions",
type: "success",
title: "Server icon updated",
text: "Your server icon was successfully changed.",
});
} catch (error) {
console.error("Error uploading icon:", error);
addNotification({
group: "serverOptions",
type: "error",
title: "Upload failed",
text: "Failed to upload server icon.",
});
}
}; };
const resetIcon = async () => { const resetIcon = async () => {
if (data.value?.image) { if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false); try {
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false); await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await new Promise((resolve) => setTimeout(resolve, 2000)); await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
await reloadNuxtApp();
addNotification({ useState(`server-icon-${props.server.serverId}`).value = undefined;
group: "serverOptions", if (data.value) data.value.image = undefined;
type: "success",
title: "Server icon reset", await props.server.refresh(["general"]);
text: "Your server icon was successfully reset.",
}); addNotification({
group: "serverOptions",
type: "success",
title: "Server icon reset",
text: "Your server icon was successfully reset.",
});
} catch (error) {
console.error("Error resetting icon:", error);
addNotification({
group: "serverOptions",
type: "error",
title: "Reset failed",
text: "Failed to reset server icon.",
});
}
} }
}; };

View File

@@ -27,7 +27,7 @@
{{ data?.sftp_host }} {{ data?.sftp_host }}
</span> </span>
<span class="text-xs uppercase text-secondary">server address</span> <span class="text-xs text-secondary">Server Address</span>
</div> </div>
<ButtonStyled type="transparent"> <ButtonStyled type="transparent">
@@ -41,9 +41,9 @@
</div> </div>
<div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row"> <div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row">
<div <div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4" class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow px-4 py-2"
> >
<div class="flex items-center justify-between"> <div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast"> <span class="font-bold text-contrast">
{{ data?.sftp_username }} {{ data?.sftp_username }}
</span> </span>
@@ -57,12 +57,12 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<span class="text-xs uppercase text-secondary">username</span> <span class="text-xs text-secondary">Username</span>
</div> </div>
<div <div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4" class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
> >
<div class="flex items-center justify-between"> <div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast"> <span class="font-bold text-contrast">
{{ {{
showPassword ? data?.sftp_password : "*".repeat(data?.sftp_password?.length ?? 0) showPassword ? data?.sftp_password : "*".repeat(data?.sftp_password?.length ?? 0)
@@ -89,7 +89,7 @@
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
<span class="text-xs uppercase text-secondary">password</span> <span class="text-xs text-secondary">Password</span>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@
</form> </form>
</NewModal> </NewModal>
<NewModal ref="editAllocationModal" header="Edit Allocation"> <NewModal ref="editAllocationModal" header="Edit allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation"> <form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation">
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label> <label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
<input <input
@@ -40,7 +40,7 @@
<div class="mb-1 mt-4 flex justify-start gap-4"> <div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit"> <button :disabled="!newAllocationName" type="submit">
<SaveIcon /> Update Allocation <SaveIcon /> Update allocation
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
@@ -59,7 +59,29 @@
/> />
<div class="relative h-full w-full overflow-y-auto"> <div class="relative h-full w-full overflow-y-auto">
<div v-if="data" class="flex h-full w-full flex-col justify-between gap-4"> <div
v-if="server.network?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load network settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's network settings. Here's what we know:
<span class="break-all font-mono">{{ JSON.stringify(server.network.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<!-- Subdomain section --> <!-- Subdomain section -->
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
@@ -94,7 +116,7 @@
/> />
<div <div
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow p-4" class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow px-4 py-2"
> >
<table <table
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300" class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
@@ -108,7 +130,7 @@
> >
{{ record.type }} {{ record.type }}
</span> </span>
<span class="text-xs uppercase text-secondary">type</span> <span class="text-xs text-secondary">Type</span>
</div> </div>
</td> </td>
<td class="w-2/6 py-3 md:w-1/3"> <td class="w-2/6 py-3 md:w-1/3">
@@ -118,7 +140,7 @@
> >
{{ record.name }} {{ record.name }}
</span> </span>
<span class="text-xs uppercase text-secondary">name</span> <span class="text-xs text-secondary">Name</span>
</div> </div>
</td> </td>
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12"> <td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
@@ -128,7 +150,7 @@
> >
{{ record.content }} {{ record.content }}
</span> </span>
<span class="text-xs uppercase text-secondary">content</span> <span class="text-xs text-secondary">Content</span>
</div> </div>
</td> </td>
</tr> </tr>
@@ -155,7 +177,7 @@
</span> </span>
</div> </div>
<ButtonStyled type="standard" color="brand" @click="showNewAllocationModal"> <ButtonStyled type="standard" @click="showNewAllocationModal">
<button class="!w-full sm:!w-auto"> <button class="!w-full sm:!w-auto">
<PlusIcon /> <PlusIcon />
<span>New allocation</span> <span>New allocation</span>
@@ -190,7 +212,7 @@
<span class="text-md font-bold tracking-wide text-contrast"> <span class="text-md font-bold tracking-wide text-contrast">
{{ allocation.name }} {{ allocation.name }}
</span> </span>
<span class="hidden text-xs uppercase text-secondary sm:block">name</span> <span class="hidden text-xs text-secondary sm:block">Name</span>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span <span
@@ -198,7 +220,7 @@
> >
{{ allocation.port }} {{ allocation.port }}
</span> </span>
<span class="hidden text-xs uppercase text-secondary sm:block">port</span> <span class="hidden text-xs text-secondary sm:block">Port</span>
</div> </div>
</div> </div>
</div> </div>
@@ -247,6 +269,7 @@ import {
SaveIcon, SaveIcon,
InfoIcon, InfoIcon,
UploadIcon, UploadIcon,
IssuesIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui"; import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue"; import { ref, computed, nextTick } from "vue";
@@ -286,12 +309,11 @@ const addNewAllocation = async () => {
try { try {
await props.server.network?.reserveAllocation(newAllocationName.value); await props.server.network?.reserveAllocation(newAllocationName.value);
await props.server.refresh(["network"]);
newAllocationModal.value?.hide(); newAllocationModal.value?.hide();
newAllocationName.value = ""; newAllocationName.value = "";
await props.server.refresh();
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",
@@ -332,8 +354,8 @@ const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return; if (allocationToDelete.value === null) return;
await props.server.network?.deleteAllocation(allocationToDelete.value); await props.server.network?.deleteAllocation(allocationToDelete.value);
await props.server.refresh(["network"]);
await props.server.refresh();
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",
@@ -349,12 +371,11 @@ const editAllocation = async () => {
try { try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value); await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
await props.server.refresh(["network"]);
editAllocationModal.value?.hide(); editAllocationModal.value?.hide();
newAllocationName.value = ""; newAllocationName.value = "";
await props.server.refresh();
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",

View File

@@ -59,6 +59,11 @@ const preferences = {
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.", "When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
implemented: true, implemented: true,
}, },
hideSubdomainLabel: {
displayName: "Hide subdomain label",
description: "When enabled, the subdomain label will be hidden from the server header.",
implemented: true,
},
autoRestart: { autoRestart: {
displayName: "Auto restart", displayName: "Auto restart",
description: "When enabled, your server will automatically restart if it crashes.", description: "When enabled, your server will automatically restart if it crashes.",
@@ -84,6 +89,7 @@ type UserPreferences = {
const defaultPreferences: UserPreferences = { const defaultPreferences: UserPreferences = {
ramAsNumber: false, ramAsNumber: false,
hideSubdomainLabel: false,
autoRestart: false, autoRestart: false,
powerDontAskAgain: false, powerDontAskAgain: false,
backupWhileRunning: false, backupWhileRunning: false,

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