Compare commits

...

95 Commits

Author SHA1 Message Date
Calum H.
42a2451990 fix: border around server icon 2025-08-05 17:38:33 +01:00
Calum H.
54958491b3 fix: scale for medal bg 2025-08-05 16:57:23 +01:00
Calum H.
b497c944a0 feat: use same gradient as promo 2025-08-05 16:55:30 +01:00
Calum H.
e89ebf6915 fix: lint 2025-08-05 13:12:11 +01:00
Calum H.
5b46f521b7 feat: countdown on server panel 2025-08-05 13:08:18 +01:00
Calum H.
e010e617c1 feat: finish server card layout 2025-08-05 12:42:53 +01:00
Calum H.
0b3cbbd37a fix: light mode medal promotion 2025-08-05 12:20:47 +01:00
IMB11
ea1789ebf6 fix: colors for dark mode only 2025-08-04 18:33:38 +01:00
IMB11
6401a8937b fix: styling changes 2025-08-04 18:08:02 +01:00
Calum H.
2da2b4aec7 feat: medal server card 2025-08-04 14:15:37 +01:00
Calum H.
d1a478dbf2 feat: medal promotion on servers page 2025-08-04 13:19:34 +01:00
Prospector
99493b9917 Updated changelog 2025-08-01 21:31:22 -04:00
IMB11
72a52eb7b1 fix: improve error message for rate limiting (#4101)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-08-01 21:27:25 +00:00
IMB11
b33e12c71d fix: startup settings not visible on hard page refresh/direct load (#4100)
* fix: startup settings not visible on hard page refresh/direct load

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

* Only show slug stuff when needed.

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

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

* Update versions.ts

remove unnecessary import

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

* Tweak summary formatting msg

* Update license messages to use flink

* reorder link text to match the settings page

* add Description clarity button

---------

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

* fix: lint issues

* fix: issues

* fix: report layout

* fix: lint

* fix: impl quick replies

* fix: remove test qr

* feat: individual report page + use new backend

* feat: memoize filtering

* feat: apply optimizations to moderation queue

* fix: lint issues

* feat: impl quick reply functionality

* fix: top level await

* fix: dep issue

* fix: dep issue x2

* fix: dep issue

* feat: intl extract

* fix: dev-187

* fix: dev-186 & review project btn

* fix: dev-176

* remove redundant moderation button from user dropdown

* correct a msg and add admin to read filter

---------

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

* fix(labrinth): retire Sendy for new email newsletter subscriptions
2025-07-29 09:51:50 +00:00
Josiah Glosson
175b90be5a Legacy ping support (#4062)
* Detection of protocol versions before 18w47b

* Refactor old_protocol_versions into protocol_version

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

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

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

* show optimization button when present in additional categories

* add more formatted link shortcuts

* Add info text to env info stage

* Only show gallery relevancy button when relevant.

* add unsupported project type message to versions stage

* Fix misuse of slug message.

* Update unsupported_project.md

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

Follow up to #3408 and #3864

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

Allows cancelling a failed subscription and forcing another charge attempt

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

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

* fix lint
2025-07-27 17:27:02 +00:00
Emma Alexia
1df6e29aa1 Ensure server status info is always passed to "My servers" page (#4071)
This took an insanely long time to debug and figure out you would not believe
2025-07-27 17:10:52 +00:00
Emma Alexia
5deb4179ad Re-enable the Moderation tab for projects that are approved (#4067)
By request of the moderation team. This would allow easier access
if, e.g., the moderators tell the author of a metadata problem they
need to correct.
2025-07-27 17:07:39 +00:00
Alejandro González
358cf31c87 feat(labrinth): basic offset pagination for moderation reports and projects (#4063) 2025-07-26 12:32:35 +00:00
Prospector
6db1d66591 else if 2025-07-24 10:38:23 -07:00
Prospector
8052fda840 Bump report limit to 1500 2025-07-24 10:37:01 -07:00
IMB11
15892a88d3 fix: handle identified files properly in the checklist (#4004)
* fix: handle identified files from the backend

* fix: allFiles not being emitted after permissions flow completed

* fix: properly handle identified projects

* fix: jade issues

* fix: import

* fix: issue with perm gen msgs

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

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

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

* chore: address Clippy lint

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

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

* tweak(ci/docker): switch to inline registry cache
2025-07-22 22:31:56 +00:00
Alejandro González
d4516d3527 feat(app): configurable Modrinth endpoints through .env files (#4015) 2025-07-21 22:55:57 +00:00
Josiah Glosson
87de47fe5e Use rust-lld linker on MSVC Windows (#4042)
The latest version of MSVC fails when linking labrinth, making now a perfect opportunity to switch over to the rust-lld linker instead.
2025-07-21 22:35:05 +00:00
Emma Alexia
7d76fe1b6a Add more info about last attempts to admin billing dashboard (#4029) 2025-07-21 08:35:36 +00:00
Prospector
ae25a15abd Update changelog 2025-07-19 15:17:39 -07:00
Prospector
0f755b94ce Revert "Author Validation Improvements (#3970)" (#4024)
This reverts commit 44267619b6.
2025-07-19 22:04:47 +00:00
Emma Alexia
bcf46d440b Count failed payments as "open" charges (#4013)
This allows people to cancel failed payments, currently it fails with error "There is no open charge for this subscription"
2025-07-19 14:33:37 +00:00
Josiah Glosson
526561f2de Add --color to intl:extract verification (#4023) 2025-07-19 12:42:17 +00:00
Emma Alexia
a8caa1afc3 Clarify that Modrinth Servers are for Java Edition (#4021) 2025-07-18 18:37:06 +00:00
Emma Alexia
98e9a8473d Fix NeoForge instance importing from MultiMC/Prism (#4016)
Fixes DEV-170
2025-07-18 13:00:11 +00:00
coolbot
936395484e fix: status alerts and version buttons no longer cause a failed to generate error. (#4017)
* add empty message to actions with no message, fixing broken message generation.

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

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

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

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

* remove unused project definition

* Switch markdown to use <br />

---------

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

* feat: start on frontend impl

* fix: shouldShow issues

* feat: continue work

* feat: re add submitting/re-submit nags

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

* fix: links page + add more validations

* feat: tags validations

* fix: lint issues

* fix: lint

* fix: issues

* feat: start on i18nifying nags

* feat: impl intl

* fix: minecraft title clause update

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-16 22:28:42 +00:00
Prospector
90043fe84d Remove tumblr from footer since it's no longer in use (#4001)
* Remove tumblr from footer since it's no longer in use

* remove import

* i18n extract

---------

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

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

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

Co-Authored-By: @coolbot100s

* misc fixes + corrections

* Add clickable link previews to links stage

* Correct mislabeled title message and add new title messages

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

* More applications of rules placeholder

* Add new status alerts stage

* change order of statusAlerts

* Update title related messages, add navigation based vars

* Overhaul Links stage and add new messages.

* Set message weights, add some disables

* message.mds now obey lint >:(

* fixed links text message formatting and changed an icon

* Combine title and slug stages

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

* tweak summary text formatting

* Improved tags stage info and more navigation placeholders

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

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

* Update modpack permissions guidance

* fix: blog path issues

* fix: lint issues

* fix license stage text formatting

* Improve license stage

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

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

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

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: IMB11 <calum@modrinth.com>
2025-07-16 18:48:26 +00:00
Silcean
eb595cdc3e Feature/detect skin variant on fileinput (#3999)
* chaged detection algorithm, and added skin variant deteciton on fileinput

* Update skins.ts

removed leftover logs

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

* fully reverted lock.yaml. my bad.

---------

Co-authored-by: Bronchiopator <70262842+Bronchiopator@users.noreply.github.com>
2025-07-16 10:43:30 +00:00
Josiah Glosson
572cd065ed Allow joining offline servers from the Worlds tab (#3998)
* Allow joining offline servers from the Worlds tab

* Run intl:extract

* Fix lint
2025-07-15 23:58:04 +00:00
Prospector
76dc8a0897 Update DDoS protection on Modrinth Servers page 2025-07-15 13:51:35 -07:00
Prospector
4723de6269 Update MRS marketing and add copyright policy to footer 2025-07-15 12:36:29 -07:00
Prospector
e15fa35bad Update changelog 2025-07-15 08:15:26 -07:00
IMB11
2cc6bc8ce4 fix: unidentified files not showing in final checklist message (#3992)
* fix: unidentified files not showing in final message

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

* break overflowing text instead of hiding it

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

* fix: fix

---------

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

* fix: dev-163

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

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

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

* fix: example doc

* feat: multiselect chips

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

* feat: migrate checklist v1 to new format.

* fix: lint issues

* fix: severity util

* feat: README.md

* feat: start implementing new moderation checklist

* feat: message assembly + fix imports

* fix: lint issues

* feat: add input suggestions

* feat: utility cleanup

* fix: icon

* chore: remove debug logging

* chore: remove debug button

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

* feat: icons + use id in stage selection button

* Support md/plain text in stages.

* fix: checklist not persisting/showing on subpages

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

* feat: better notification placement + queue navigation

* fix: default props for futureProjects

* fix: modpack perms message

* fix: issue with future projects props

* fix: tab index + z index fixes

* feat: keybinds

* fix: file approval types

* fix: generate message for non-modpack projects

* feat: add generate message to stages dropdown

* fix: variables not expanding

* feat: requests

* fix: empty message approval

* fix: issues from sync

* chore: add comment for old moderation checklist impl

* fix: git artifacts

* fix: update visibility logic for stages and actions

* fix: cleanup logic for should show

* fix: markdown editor accidental edit
2025-07-11 16:09:04 +00:00
Prospector
f7700acce4 Updated changelog 2025-07-09 22:12:32 -07:00
IMB11
87a3e2d022 fix: white cape (#3959) 2025-07-09 23:31:00 +00:00
Prospector
5d17663040 Don't unnecessarily markdown-ify links pasted into markdown editor (#3958) 2025-07-09 23:11:17 +00:00
ToBinio
cff3c72f94 feat(theseus): add snapPoints for memory sliders (#1275)
* feat: add snapPoints for memory sliders

* fix lint

* Reapply changes

* Hide snap point display when disabled

* fix unused imports

---------

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

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

I've set the following values:

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

The following values have been left unset:

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

* fix(docs): reduce security.txt expiry

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

From the RFC:

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

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

* fix(frontend): extend security.txt expiry

It takes so long to merge the PR :(

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

* docs(frontend) careers link in security.txt

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

---------

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

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

* lint

---------

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

* extract messages

* extract messages again
2025-07-09 22:27:59 +00:00
IMB11
cb72d2ac80 Skins improvements/fixes (#3943)
* feat: only initialize batch renderer if needed & head storage

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

* fix: performance improvements with cache loading+saving

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

* feat: antialiasing

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

* fix: lint issues

* fix: lint issues

* feat: tweaks to radial spotlight

* fix: app nav btn colors
2025-07-09 21:41:36 +00:00
Nitrrine
3c79607d1f feat(app): increase logs card height (#3953) 2025-07-09 21:39:51 +00:00
Alejandro González
36ad1f16e4 ci(theseus): assorted tweaks and fixes (#3949)
* ci(theseus-build): ensure only relevant bundle artifacts are uploaded

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

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

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

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

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

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

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

This reverts commit e0cde2d6ff.

* fix: polling issues

* fix: circuit breaker logic for spam

* fix: remove polling & ping test node instead

* fix: remove broken url from debugging

* fix: show error information display if node access fails in fs module
2025-07-08 17:40:44 +00:00
Prospector
f256ef43c0 Add x-archon-request header 2025-07-07 22:16:26 -07:00
Prospector
e0cde2d6ff Revert "fix: error handling improvements (#3797)"
This reverts commit 706976439d.
2025-07-07 17:37:43 -07:00
Prospector
e4e77dc0d2 Revert "temp: do not retry MRS requests"
This reverts commit 8ba6467f21.
2025-07-07 17:07:27 -07:00
Prospector
8ba6467f21 temp: do not retry MRS requests 2025-07-07 16:49:17 -07:00
Josiah Glosson
088cb54317 Fix failure when "Test"ing a Java installation (#3935)
* Fix failure when "Test"ing a Java installation

* Fix lint
2025-07-07 19:11:36 +00:00
Josiah Glosson
c47bcf665d Fix MinecraftLaunch failing in the case of a package-private main class on Java 8 (#3932)
I don't know of any mod loaders where this is the case, but better be safe than sorry
2025-07-07 15:42:38 +00:00
Prospector
bc90c27e27 Add ?new to url to give it a new key 2025-07-07 01:18:40 -07:00
Prospector
c1be57773a Update changelog 2025-07-07 01:10:51 -07:00
IMB11
315c68912c fix: use watch for links not mount event (#3929) 2025-07-07 08:01:21 +00:00
353 changed files with 16876 additions and 4369 deletions

View File

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

1
.dockerignore Symbolic link
View File

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

View File

@@ -22,23 +22,26 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Fetch docker metadata
id: docker_meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: ghcr.io/modrinth/daedalus
- name: Login to GitHub Images
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with:
file: ./apps/daedalus_client/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/daedalus:main
cache-to: type=inline

View File

@@ -18,30 +18,28 @@ on:
jobs:
docker:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./apps/labrinth
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Fetch docker metadata
id: docker_meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: ghcr.io/modrinth/labrinth
- name: Login to GitHub Images
uses: docker/login-action@v1
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
env:
SQLX_OFFLINE: true
uses: docker/build-push-action@v6
with:
file: ./apps/labrinth/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/labrinth:main
cache-to: type=inline

View File

@@ -43,7 +43,7 @@ jobs:
- name: 📥 Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
fetch-depth: 0
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
@@ -75,15 +75,17 @@ jobs:
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: ⚙️ Set application version
- name: ⚙️ Set application version and environment
shell: bash
env:
APP_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || format('v1.0.0-canary+{0}', github.sha) }}
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
echo "Setting application version to $APP_VERSION"
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
@@ -100,12 +102,6 @@ jobs:
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
fi
- name: 🗑️ Clean up cached bundles
shell: bash
run: |
rm -rf target/release/bundle
rm -rf target/*/release/bundle || true
- name: 🔨 Build macOS app
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
if: startsWith(matrix.platform, 'macos')
@@ -147,5 +143,10 @@ jobs:
with:
name: App bundle (${{ matrix.artifact-target-name }})
path: |
target/release/bundle/**
target/*/release/bundle/**
target/release/bundle/appimage/Modrinth App_*.AppImage*
target/release/bundle/deb/Modrinth App_*.deb*
target/release/bundle/rpm/Modrinth App-*.rpm*
target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz*
target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg*
target/release/bundle/nsis/Modrinth App_*-setup.exe*
target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip*

View File

@@ -67,7 +67,7 @@ jobs:
"install_urls": [
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.deb")",
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage")",
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "-1.x86_64.rpm")"
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App-" + $versionTag + "-1.x86_64.rpm")"
]
},
"windows-x86_64": {

View File

@@ -52,7 +52,7 @@ jobs:
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: 🧰 Setup cargo-sqlx
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
uses: taiki-e/cache-cargo-install-action@v2
with:
tool: sqlx-cli
locked: false
@@ -74,5 +74,14 @@ jobs:
cp .env.local .env
sqlx database setup
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
- name: 🔍 Lint and test
run: pnpm run ci
- name: 🔍 Verify intl:extract has been run
run: |
pnpm intl:extract
git diff --exit-code --color */*/src/locales/en-US/index.json

160
Cargo.lock generated
View File

@@ -1706,7 +1706,7 @@ dependencies = [
"bitflags 2.9.1",
"core-foundation 0.10.0",
"core-graphics-types",
"foreign-types 0.5.0",
"foreign-types",
"libc",
]
@@ -2699,15 +2699,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -2715,7 +2706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
"foreign-types-shared",
]
[[package]]
@@ -2729,12 +2720,6 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -3678,11 +3663,10 @@ dependencies = [
[[package]]
name = "hyper-rustls"
version = "0.27.5"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"futures-util",
"http 1.3.1",
"hyper 1.6.0",
"hyper-util",
@@ -3692,7 +3676,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.2",
"tower-service",
"webpki-roots 0.26.11",
"webpki-roots 1.0.0",
]
[[package]]
@@ -3708,22 +3692,6 @@ dependencies = [
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.6.0",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.14"
@@ -4389,7 +4357,7 @@ dependencies = [
"futures-util",
"hex",
"hmac",
"hyper-tls",
"hyper-rustls 0.27.7",
"hyper-util",
"iana-time-zone",
"image",
@@ -4986,23 +4954,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -5577,50 +5528,12 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -5818,6 +5731,17 @@ dependencies = [
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_macros 0.12.1",
"phf_shared 0.12.1",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.8.0"
@@ -5868,6 +5792,16 @@ dependencies = [
"rand 0.8.5",
]
[[package]]
name = "phf_generator"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b"
dependencies = [
"fastrand 2.3.0",
"phf_shared 0.12.1",
]
[[package]]
name = "phf_macros"
version = "0.10.0"
@@ -5895,6 +5829,19 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "phf_macros"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368"
dependencies = [
"phf_generator 0.12.1",
"phf_shared 0.12.1",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "phf_shared"
version = "0.8.0"
@@ -5922,6 +5869,15 @@ dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -6850,7 +6806,7 @@ dependencies = [
"http-body 1.0.1",
"http-body-util",
"hyper 1.6.0",
"hyper-rustls 0.27.5",
"hyper-rustls 0.27.7",
"hyper-util",
"js-sys",
"log",
@@ -7980,7 +7936,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types 0.5.0",
"foreign-types",
"js-sys",
"log",
"objc2 0.5.2",
@@ -9017,6 +8973,7 @@ dependencies = [
"data-url",
"dirs",
"discord-rich-presence",
"dotenvy",
"dunce",
"either",
"encoding_rs",
@@ -9032,6 +8989,7 @@ dependencies = [
"notify-debouncer-mini",
"p256",
"paste",
"phf 0.12.1",
"png",
"quartz_nbt",
"quick-xml 0.37.5",
@@ -9071,6 +9029,8 @@ dependencies = [
"dashmap",
"either",
"enumset",
"hyper 1.6.0",
"hyper-util",
"native-dialog",
"paste",
"serde",
@@ -9292,16 +9252,6 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"

View File

@@ -67,7 +67,13 @@ heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper-tls = "0.6.0"
hyper = "1.6.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"http1",
"native-tokio",
"ring",
"tls12",
] }
hyper-util = "0.1.14"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
@@ -93,6 +99,7 @@ notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2"
paste = "1.0.15"
phf = { version = "0.12.1", features = ["macros"] }
png = "0.17.16"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"

View File

@@ -61,9 +61,10 @@ import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
@@ -263,6 +264,8 @@ const incompatibilityWarningModal = ref()
const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
@@ -272,8 +275,24 @@ async function fetchCredentials() {
}
async function signIn() {
await login().catch(handleError)
await fetchCredentials()
modrinthLoginFlowWaitModal.value.show()
try {
await login()
await fetchCredentials()
} catch (error) {
if (
typeof error === 'object' &&
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// Not really an error due to being a result of user interaction, show nothing
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
}
async function logOut() {
@@ -402,6 +421,9 @@ function handleAuxClick(e) {
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
</Suspense>
<Suspense>
<InstanceCreationModal ref="installationModal" />
</Suspense>
@@ -485,13 +507,13 @@ function handleAuxClick(e) {
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div class="flex items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()"
>
<LeftArrowIcon />
</button>
<button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.forward()"
>
<RightArrowIcon />

View File

@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
if (sortBy.value === 'Game version') {
instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version)
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
})
}
@@ -213,6 +213,17 @@ const filteredResults = computed(() => {
instanceMap.set(entry[0], entry[1])
})
}
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group.value === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap
})

View File

@@ -305,12 +305,16 @@ const [
get_game_versions().then(shallowRef).catch(handleError),
get_loaders()
.then((value) =>
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
ref(
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
)
.then(ref)
.catch(handleError),
.catch((err) => {
handleError(err)
return ref([])
}),
])
loaders.value.unshift('vanilla')

View File

@@ -108,7 +108,6 @@ async function testJava() {
testingJava.value = true
testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '',
1,
props.version,
)
testingJava.value = false

View File

@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
import useMemorySlider from '@/composables/useMemorySlider'
const { formatMessage } = useVIntl()
@@ -34,7 +34,7 @@ const envVars = ref(
const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.memory)
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
const { maxMemory, snapPoints } = await useMemorySlider()
const editProfileObject = computed(() => {
const editProfile: {
@@ -156,6 +156,8 @@ const messages = defineMessages({
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">

View File

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

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import { Slider, Toggle } from '@modrinth/ui'
import useMemorySlider from '@/composables/useMemorySlider'
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
@@ -11,7 +10,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
const settings = ref(fetchSettings)
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
const { maxMemory, snapPoints } = await useMemorySlider()
watch(
settings,
@@ -107,6 +106,8 @@ watch(
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>

View File

@@ -118,6 +118,7 @@ import {
type Cape,
type SkinModel,
get_normalized_skin_texture,
determineModelType,
} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import {
@@ -253,7 +254,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl
variant.value = 'CLASSIC'
variant.value = await determineModelType(skinTextureUrl)
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import {
type ProtocolVersion,
type ServerWorld,
type ServerData,
type WorldWithProfile,
@@ -33,7 +34,7 @@ const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, number | null>>({})
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6
@@ -121,11 +122,8 @@ async function populateJumpBackIn() {
}
})
// fetch each server's data
Promise.all(
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
),
servers.forEach(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
)
}
@@ -150,8 +148,8 @@ async function populateJumpBackIn() {
.slice(0, MAX_JUMP_BACK_IN)
}
async function refreshServer(address: string, instancePath: string) {
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
function refreshServer(address: string, instancePath: string) {
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
}
async function joinWorld(world: WorldWithProfile) {

View File

@@ -1,7 +1,14 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import type {
ProtocolVersion,
ServerStatus,
ServerWorld,
SingleplayerWorld,
World,
set_world_display_status,
getWorldIdentifier,
} from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import {
useRelativeTime,
@@ -55,7 +62,7 @@ const props = withDefaults(
playingWorld?: boolean
startingInstance?: boolean
supportsQuickPlay?: boolean
currentProtocol?: number | null
currentProtocol?: ProtocolVersion | null
highlighted?: boolean
// Server only
@@ -102,7 +109,8 @@ const serverIncompatible = computed(
!!props.serverStatus &&
!!props.serverStatus.version?.protocol &&
!!props.currentProtocol &&
props.serverStatus.version.protocol !== props.currentProtocol,
(props.serverStatus.version.protocol !== props.currentProtocol.version ||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
@@ -128,6 +136,14 @@ const messages = defineMessages({
id: 'instance.worlds.game_already_open',
defaultMessage: 'Instance is already open',
},
noContact: {
id: 'instance.worlds.no_contact',
defaultMessage: "Server couldn't be contacted",
},
incompatibleServer: {
id: 'instance.worlds.incompatible_server',
defaultMessage: 'Server is incompatible',
},
copyAddress: {
id: 'instance.worlds.copy_address',
defaultMessage: 'Copy address',
@@ -302,39 +318,33 @@ const messages = defineMessages({
</template>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<template v-if="world.type === 'singleplayer' || serverStatus">
<ButtonStyled
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
color="red"
>
<button @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
serverIncompatible
? 'Server is incompatible'
<ButtonStyled
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
color="red"
>
<button @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
!serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: !supportsQuickPlay
? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else>
<button class="invisible">
<PlayIcon aria-hidden="true" />
"
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>

View File

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

View File

@@ -36,8 +36,8 @@ export async function get_jre(path) {
// Tests JRE version by running 'java -version' on it.
// Returns true if the version is valid, and matches given (after extraction)
export async function test_jre(path, majorVersion, minorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
export async function test_jre(path, majorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
}
// Automatically installs specified java version

View File

@@ -16,3 +16,7 @@ export async function logout() {
export async function get() {
return await invoke('plugin:mr-auth|get')
}
export async function cancelLogin() {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}

View File

@@ -2,25 +2,46 @@ import * as THREE from 'three'
import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue'
import { setupSkinModel, disposeCaches } from '@modrinth/utils'
import {
setupSkinModel,
disposeCaches,
loadTexture,
applyCapeTexture,
createTransparentTexture,
} from '@modrinth/utils'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import { headStorage } from '../storage/head-storage'
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
export interface RenderResult {
forwards: string
backwards: string
}
export interface RawRenderResult {
forwards: Blob
backwards: Blob
}
class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer
private readonly scene: THREE.Scene
private readonly camera: THREE.PerspectiveCamera
private renderer: THREE.WebGLRenderer | null = null
private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null
private readonly width: number
private readonly height: number
constructor(width: number = 360, height: number = 504) {
this.width = width
this.height = height
}
private initializeRenderer(): void {
if (this.renderer) return
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.width = this.width
canvas.height = this.height
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
@@ -33,10 +54,10 @@ class BatchSkinRenderer {
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(width, height)
this.renderer.setSize(this.width, this.height)
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000)
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
@@ -50,9 +71,12 @@ class BatchSkinRenderer {
textureUrl: string,
modelUrl: string,
capeUrl?: string,
capeModelUrl?: string,
): Promise<RenderResult> {
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
): Promise<RawRenderResult> {
this.initializeRenderer()
this.clearScene()
await this.setupModel(modelUrl, textureUrl, capeUrl)
const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number]
@@ -77,35 +101,35 @@ class BatchSkinRenderer {
private async renderView(
cameraPosition: [number, number, number],
lookAtPosition: [number, number, number],
): Promise<string> {
): Promise<Blob> {
if (!this.camera || !this.renderer || !this.scene) {
throw new Error('Renderer not initialized')
}
this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera)
return new Promise<string>((resolve, reject) => {
this.renderer.domElement.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob)
resolve(url)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
})
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
const response = await fetch(dataUrl)
return await response.blob()
}
private async setupModel(
modelUrl: string,
textureUrl: string,
capeModelUrl?: string,
capeUrl?: string,
): Promise<void> {
if (this.currentModel) {
this.scene.remove(this.currentModel)
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
if (!this.scene) {
throw new Error('Renderer not initialized')
}
const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
const { model } = await setupSkinModel(modelUrl, textureUrl)
if (capeUrl) {
const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture)
} else {
const transparentTexture = createTransparentTexture()
applyCapeTexture(model, null, transparentTexture)
}
const group = new THREE.Group()
group.add(model)
@@ -116,8 +140,39 @@ class BatchSkinRenderer {
this.currentModel = group
}
private clearScene(): void {
if (!this.scene) return
while (this.scene.children.length > 0) {
const child = this.scene.children[0]
this.scene.remove(child)
if (child instanceof THREE.Mesh) {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())
} else {
child.material.dispose()
}
}
}
}
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
this.currentModel = null
}
public dispose(): void {
this.renderer.dispose()
if (this.renderer) {
this.renderer.dispose()
}
disposeCaches()
}
}
@@ -133,10 +188,25 @@ function getModelUrlForVariant(variant: string): string {
}
}
export const map = reactive(new Map<string, RenderResult>())
export const headMap = reactive(new Map<string, string>())
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
}
return sharedRenderer
}
export function disposeSharedRenderer(): void {
if (sharedRenderer) {
sharedRenderer.dispose()
sharedRenderer = null
}
}
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>()
const validHeadKeys = new Set<string>()
@@ -150,7 +220,7 @@ export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys)
await headStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) {
console.warn('Failed to cleanup unused skin previews:', error)
}
@@ -229,13 +299,17 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
}
outputCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
outputCanvas.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from canvas'))
}
},
'image/webp',
0.9,
)
} catch (error) {
reject(error)
}
@@ -252,35 +326,24 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = `${skin.texture_key}-head`
if (headMap.has(headKey)) {
if (headBlobUrlMap.has(headKey)) {
if (DEBUG_MODE) {
const url = headMap.get(headKey)!
const url = headBlobUrlMap.get(headKey)!
URL.revokeObjectURL(url)
headMap.delete(headKey)
headBlobUrlMap.delete(headKey)
} else {
return headMap.get(headKey)!
return headBlobUrlMap.get(headKey)!
}
}
try {
const cached = await skinPreviewStorage.retrieve(headKey)
if (cached && typeof cached === 'string') {
headMap.set(headKey, cached)
return cached
}
} catch (error) {
console.warn('Failed to retrieve cached head render:', error)
}
const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
const headUrl = URL.createObjectURL(headBlob)
headMap.set(headKey, headUrl)
headBlobUrlMap.set(headKey, headUrl)
try {
// @ts-expect-error - skinPreviewStorage.store expects a RenderResult, but we are storing a string url.
await skinPreviewStorage.store(headKey, headUrl)
await headStorage.store(headKey, headBlob)
} catch (error) {
console.warn('Failed to store head render in persistent storage:', error)
}
@@ -293,30 +356,49 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
}
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const renderer = new BatchSkinRenderer()
try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
)
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
skinPreviewStorage.batchRetrieve(skinKeys),
headStorage.batchRetrieve(headKeys),
])
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
const rawCached = cachedSkinPreviews[skinKey]
if (rawCached) {
const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards),
backwards: URL.createObjectURL(rawCached.backwards),
}
skinBlobUrlMap.set(skinKey, cached)
}
const cachedHead = cachedHeadPreviews[headKey]
if (cachedHead) {
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
}
}
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (map.has(key)) {
if (skinBlobUrlMap.has(key)) {
if (DEBUG_MODE) {
const result = map.get(key)!
const result = skinBlobUrlMap.get(key)!
URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards)
map.delete(key)
skinBlobUrlMap.delete(key)
} else continue
}
try {
const cached = await skinPreviewStorage.retrieve(key)
if (cached) {
map.set(key, cached)
continue
}
} catch (error) {
console.warn('Failed to retrieve cached skin preview:', error)
}
const renderer = getSharedRenderer()
let variant = skin.variant
if (variant === 'UNKNOWN') {
@@ -330,25 +412,35 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
const modelUrl = getModelUrlForVariant(variant)
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
const renderResult = await renderer.renderSkin(
const rawRenderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin),
modelUrl,
cape?.texture,
CapeModel,
)
map.set(key, renderResult)
const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards),
}
skinBlobUrlMap.set(key, renderResult)
try {
await skinPreviewStorage.store(key, renderResult)
await skinPreviewStorage.store(key, rawRenderResult)
} catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error)
}
await generateHeadRender(skin)
const headKey = `${skin.texture_key}-head`
if (!headBlobUrlMap.has(headKey)) {
await generateHeadRender(skin)
}
}
} finally {
renderer.dispose()
disposeSharedRenderer()
await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
}
}

View File

@@ -62,15 +62,12 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
context.drawImage(image, 0, 0)
const armX = 44
const armY = 16
const armWidth = 4
const armX = 54
const armY = 20
const armWidth = 2
const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let y = 0; y < armHeight; y++) {
const alphaIndex = (3 + y * armWidth) * 4 + 3
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC')
return

View File

@@ -0,0 +1,229 @@
interface StoredHead {
blob: Blob
timestamp: number
}
export class HeadStorage {
private dbName = 'head-storage'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('heads')) {
db.createObjectStore('heads')
}
}
})
}
async store(key: string, blob: Blob): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
const storedHead: StoredHead = {
blob,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedHead, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<string | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (!result) {
resolve(null)
return
}
const url = URL.createObjectURL(result.blob)
resolve(url)
}
request.onerror = () => reject(request.error)
})
}
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
const results: Record<string, Blob | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (result) {
results[key] = result.blob
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid head entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredHead
const entrySize = value.blob.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Head Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
async clearAll(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
}
export const headStorage = new HeadStorage()

View File

@@ -1,4 +1,4 @@
import type { RenderResult } from '../rendering/batch-skin-renderer'
import type { RawRenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview {
forwards: Blob
@@ -30,18 +30,15 @@ export class SkinPreviewStorage {
})
}
async store(key: string, result: RenderResult): Promise<void> {
async store(key: string, result: RawRenderResult): Promise<void> {
if (!this.db) await this.init()
const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews')
const storedPreview: StoredPreview = {
forwards: forwardsBlob,
backwards: backwardsBlob,
forwards: result.forwards,
backwards: result.backwards,
timestamp: Date.now(),
}
@@ -53,7 +50,7 @@ export class SkinPreviewStorage {
})
}
async retrieve(key: string): Promise<RenderResult | null> {
async retrieve(key: string): Promise<RawRenderResult | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
@@ -70,14 +67,56 @@ export class SkinPreviewStorage {
return
}
const forwards = URL.createObjectURL(result.forwards)
const backwards = URL.createObjectURL(result.backwards)
resolve({ forwards, backwards })
resolve({ forwards: result.forwards, backwards: result.backwards })
}
request.onerror = () => reject(request.error)
})
}
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
const results: Record<string, RawRenderResult | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (result) {
results[key] = { forwards: result.forwards, backwards: result.backwards }
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
@@ -113,6 +152,67 @@ export class SkinPreviewStorage {
request.onerror = () => reject(request.error)
})
}
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredPreview
const entrySize = value.forwards.size + value.backwards.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Skin Preview Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
}
export const skinPreviewStorage = new SkinPreviewStorage()

View File

@@ -51,6 +51,7 @@ export type ServerStatus = {
version?: {
name: string
protocol: number
legacy: boolean
}
favicon?: string
enforces_secure_chat: boolean
@@ -70,11 +71,17 @@ export interface Chat {
export type ServerData = {
refreshing: boolean
lastSuccessfulRefresh?: number
status?: ServerStatus
rawMotd?: string | Chat
renderedMotd?: string
}
export type ProtocolVersion = {
version: number
legacy: boolean
}
export async function get_recent_worlds(
limit: number,
displayStatuses?: DisplayStatus[],
@@ -156,13 +163,13 @@ export async function remove_server_from_profile(path: string, index: number): P
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
}
export async function get_profile_protocol_version(path: string): Promise<number | null> {
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
}
export async function get_server_status(
address: string,
protocolVersion: number | null = null,
protocolVersion: ProtocolVersion | null = null,
): Promise<ServerStatus> {
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
}
@@ -206,30 +213,39 @@ export function isServerWorld(world: World): world is ServerWorld {
export async function refreshServerData(
serverData: ServerData,
protocolVersion: number | null,
protocolVersion: ProtocolVersion | null,
address: string,
): Promise<void> {
const refreshTime = Date.now()
serverData.refreshing = true
await get_server_status(address, protocolVersion)
.then((status) => {
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
// Don't update if there was a more recent successful refresh
return
}
serverData.lastSuccessfulRefresh = Date.now()
serverData.status = status
if (status.description) {
serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description)
}
})
.catch((err) => {
console.error(`Refreshing addr: ${address}`, err)
})
.finally(() => {
serverData.refreshing = false
})
.catch((err) => {
console.error(`Refreshing addr ${address}`, protocolVersion, err)
if (!protocolVersion?.legacy) {
refreshServerData(serverData, { version: 74, legacy: true }, address)
}
})
}
export async function refreshServers(
export function refreshServers(
worlds: World[],
serverData: Record<string, ServerData>,
protocolVersion: number | null,
protocolVersion: ProtocolVersion | null,
) {
const servers = worlds.filter(isServerWorld)
servers.forEach((server) => {
@@ -243,10 +259,8 @@ export async function refreshServers(
})
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
Promise.all(
Object.keys(serverData).map((address) =>
refreshServerData(serverData[address], protocolVersion, address),
),
Object.keys(serverData).forEach((address) =>
refreshServerData(serverData[address], protocolVersion, address),
)
}

View File

@@ -377,6 +377,12 @@
"instance.worlds.hardcore": {
"message": "Hardcore mode"
},
"instance.worlds.incompatible_server": {
"message": "Server is incompatible"
},
"instance.worlds.no_contact": {
"message": "Server couldn't be contacted"
},
"instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+"
},

View File

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

View File

@@ -38,7 +38,7 @@ import {
import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
@@ -215,7 +215,7 @@ async function loadCurrentUser() {
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return map.get(key)
return skinBlobUrlMap.get(key)
}
async function login() {

View File

@@ -483,7 +483,7 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 1rem;
height: calc(100vh - 11rem);
height: 100vh;
}
.button-row {

View File

@@ -134,6 +134,7 @@ import {
} from '@modrinth/ui'
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
import {
type ProtocolVersion,
type SingleplayerWorld,
type World,
type ServerWorld,
@@ -210,7 +211,9 @@ const worldPlaying = ref<World>()
const worlds = ref<World[]>([])
const serverData = ref<Record<string, ServerData>>({})
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
const protocolVersion = ref<ProtocolVersion | null>(
await get_profile_protocol_version(instance.value.path),
)
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
@@ -246,7 +249,7 @@ async function refreshAllWorlds() {
worlds.value = await refreshWorlds(instance.value.path).finally(
() => (refreshingAll.value = false),
)
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
refreshServers(worlds.value, serverData.value, protocolVersion.value)
const hasNoWorlds = worlds.value.length === 0

View File

@@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?;
println!("Open URL {} in a browser", login.redirect_uri.as_str());
println!("Open URL {} in a browser", login.auth_request_uri.as_str());
println!("Please enter URL code: ");
let mut input = String::new();

View File

@@ -31,6 +31,8 @@ thiserror.workspace = true
daedalus.workspace = true
chrono.workspace = true
either.workspace = true
hyper = { workspace = true, features = ["server"] }
hyper-util.workspace = true
url.workspace = true
urlencoding.workspace = true

View File

@@ -120,7 +120,12 @@ fn main() {
.plugin(
"mr-auth",
InlinedPlugin::new()
.commands(&["modrinth_login", "logout", "get"])
.commands(&[
"modrinth_login",
"logout",
"get",
"cancel_modrinth_login",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),

View File

@@ -33,7 +33,7 @@ pub async fn login<R: Runtime>(
let window = tauri::WebviewWindowBuilder::new(
&app,
"signin",
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|_| {
theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(),
@@ -77,6 +77,7 @@ pub async fn login<R: Runtime>(
window.close()?;
Ok(None)
}
#[tauri::command]
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?)

View File

@@ -22,6 +22,8 @@ pub mod cache;
pub mod friends;
pub mod worlds;
mod oauth_utils;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
// // Main returnable Theseus GUI error

View File

@@ -1,79 +1,70 @@
use crate::api::Result;
use chrono::{Duration, Utc};
use crate::api::TheseusSerializableError;
use crate::api::oauth_utils;
use tauri::Manager;
use tauri::Runtime;
use tauri::plugin::TauriPlugin;
use tauri::{Manager, Runtime, UserAttentionType};
use tauri_plugin_opener::OpenerExt;
use theseus::prelude::*;
use tokio::sync::oneshot;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("mr-auth")
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,])
.invoke_handler(tauri::generate_handler![
modrinth_login,
logout,
get,
cancel_modrinth_login,
])
.build()
}
#[tauri::command]
pub async fn modrinth_login<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<ModrinthCredentials>> {
let redirect_uri = mr_auth::authenticate_begin_flow();
) -> Result<ModrinthCredentials> {
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
auth_code_recv_socket_tx,
));
let start = Utc::now();
let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
if let Some(window) = app.get_webview_window("modrinth-signin") {
window.close()?;
}
let auth_request_uri = format!(
"{}?launcher=true&ipver={}&port={}",
mr_auth::authenticate_begin_flow(),
if auth_code_recv_socket.is_ipv4() {
"4"
} else {
"6"
},
auth_code_recv_socket.port()
);
let window = tauri::WebviewWindowBuilder::new(
&app,
"modrinth-signin",
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| {
theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(),
app.opener()
.open_url(auth_request_uri, None::<&str>)
.map_err(|e| {
TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError(format!(
"Failed to open auth request URI: {e}"
))
.into(),
)
.as_error()
})?),
)
.min_inner_size(420.0, 632.0)
.inner_size(420.0, 632.0)
.max_inner_size(420.0, 632.0)
.zoom_hotkeys_enabled(false)
.title("Sign into Modrinth")
.always_on_top(true)
.center()
.build()?;
})?;
window.request_user_attention(Some(UserAttentionType::Critical))?;
let Some(auth_code) = auth_code.await.unwrap()? else {
return Err(TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
));
};
while (Utc::now() - start) < Duration::minutes(10) {
if window.title().is_err() {
// user closed window, cancelling flow
return Ok(None);
}
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
if window
.url()?
.as_str()
.starts_with("https://launcher-files.modrinth.com")
{
let url = window.url()?;
let code = url.query_pairs().find(|(key, _)| key == "code");
window.close()?;
return if let Some((_, code)) = code {
let val = mr_auth::authenticate_finish_flow(&code).await?;
Ok(Some(val))
} else {
Ok(None)
};
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
if let Some(main_window) = app.get_window("main") {
main_window.set_focus().ok();
}
window.close()?;
Ok(None)
Ok(credentials)
}
#[tauri::command]
@@ -85,3 +76,8 @@ pub async fn logout() -> Result<()> {
pub async fn get() -> Result<Option<ModrinthCredentials>> {
Ok(theseus::mr_auth::get_credentials().await?)
}
#[tauri::command]
pub fn cancel_modrinth_login() {
oauth_utils::auth_code_reply::stop_listeners();
}

View File

@@ -0,0 +1,159 @@
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
//!
//! This server is needed for the step 4 of the OAuth authentication dance represented in
//! figure 1 of [RFC 8252].
//!
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
//!
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::{LazyLock, Mutex},
time::Duration,
};
use hyper::body::Incoming;
use hyper_util::rt::{TokioIo, TokioTimer};
use theseus::ErrorKind;
use tokio::{
net::TcpListener,
sync::{broadcast, oneshot},
};
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
LazyLock::new(|| broadcast::channel(1024).0);
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
/// by listening on the counterpart channel for `listen_socket_tx`.
///
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
pub async fn listen(
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
) -> Result<Option<String>, theseus::Error> {
// IPv4 is tried first for the best compatibility and performance with most systems.
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
// to prevent failures deriving from improper name resolution setup. Any available
// ephemeral port is used to prevent conflicts with other services. This is all as per
// RFC 8252's recommendations
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
];
let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await {
Ok(listener) => {
listen_socket_tx
.send(listener.local_addr().map_err(|e| {
ErrorKind::OtherError(format!(
"Failed to get auth code reply socket address: {e}"
))
.into()
}))
.ok();
listener
}
Err(e) => {
let error_msg =
format!("Failed to bind auth code reply socket: {e}");
listen_socket_tx
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
.ok();
return Err(ErrorKind::OtherError(error_msg).into());
}
};
let mut auth_code = Mutex::new(None);
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
while auth_code.get_mut().unwrap().is_none() {
let client_socket = tokio::select! {
biased;
_ = shutdown_notification.recv() => {
break;
}
conn_accept_result = listener.accept() => {
match conn_accept_result {
Ok((socket, _)) => socket,
Err(e) => {
tracing::warn!("Failed to accept auth code reply: {e}");
continue;
}
}
}
};
if let Err(e) = hyper::server::conn::http1::Builder::new()
.keep_alive(false)
.header_read_timeout(Duration::from_secs(5))
.timer(TokioTimer::new())
.auto_date_header(false)
.serve_connection(
TokioIo::new(client_socket),
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
)
.await
{
tracing::warn!("Failed to handle auth code reply: {e}");
}
}
Ok(auth_code.into_inner().unwrap())
}
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
pub fn stop_listeners() {
SERVER_SHUTDOWN.send(()).ok();
}
async fn handle_reply(
req: hyper::Request<Incoming>,
auth_code_out: &Mutex<Option<String>>,
) -> Result<hyper::Response<String>, hyper::http::Error> {
if req.method() != hyper::Method::GET {
return hyper::Response::builder()
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET")
.body("".into());
}
// The authorization code is guaranteed to be sent as a "code" query parameter
// in the request URI query string as per RFC 6749 § 4.1.2
let auth_code = req.uri().query().and_then(|query_string| {
query_string
.split('&')
.filter_map(|query_pair| query_pair.split_once('='))
.find_map(|(key, value)| (key == "code").then_some(value))
});
let response = if let Some(auth_code) = auth_code {
*auth_code_out.lock().unwrap() = Some(auth_code.to_string());
hyper::Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Success")
.replace("{{message}}", "You have successfully signed in! You can close this page now."),
)
} else {
hyper::Response::builder()
.status(hyper::StatusCode::BAD_REQUEST)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Error")
.replace("{{message}}", "Authorization code not found. Please try signing in again."),
)
}?;
Ok(response)
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
//! Assorted utilities for OAuth 2.0 authorization flows.
pub mod auth_code_reply;

View File

@@ -5,8 +5,8 @@ use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata;
use theseus::profile::{QuickPlayType, get_full_path};
use theseus::worlds::{
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
WorldWithProfile,
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
WorldType, WorldWithProfile,
};
use theseus::{profile, worlds};
@@ -183,14 +183,16 @@ pub async fn remove_server_from_profile(
}
#[tauri::command]
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
pub async fn get_profile_protocol_version(
path: &str,
) -> Result<Option<ProtocolVersion>> {
Ok(worlds::get_profile_protocol_version(path).await?)
}
#[tauri::command]
pub async fn get_server_status(
address: &str,
protocol_version: Option<i32>,
protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> {
Ok(worlds::get_server_status(address, protocol_version).await?)
}

View File

@@ -63,6 +63,7 @@
"height": 800,
"resizable": true,
"title": "Modrinth App",
"label": "main",
"width": 1280,
"minHeight": 700,
"minWidth": 1100,

View File

@@ -1,21 +1,27 @@
# syntax=docker/dockerfile:1
FROM rust:1.88.0 AS build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /usr/src/daedalus
COPY . .
RUN cargo build --release --package daedalus_client
RUN --mount=type=cache,target=/usr/src/daedalus/target \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry \
cargo build --release --package daedalus_client
FROM build AS artifacts
RUN --mount=type=cache,target=/usr/src/daedalus/target \
mkdir /daedalus \
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN update-ca-certificates
COPY --from=artifacts /daedalus /daedalus
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
WORKDIR /daedalus_client
CMD /daedalus/daedalus_client
CMD ["/daedalus/daedalus_client"]

View File

@@ -19,8 +19,6 @@ From there, you can create the database and perform all database migrations with
sqlx database setup
```
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
To enable labrinth to create a project, you need to add two things.
1. An entry in the `loaders` table.

View File

@@ -38,9 +38,10 @@
"@intercom/messenger-js-sdk": "^0.0.14",
"@ltd/j-toml": "^1.38.0",
"@modrinth/assets": "workspace:*",
"@modrinth/blog": "workspace:*",
"@modrinth/moderation": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@modrinth/blog": "workspace:*",
"@pinia/nuxt": "^0.5.1",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1",
@@ -58,9 +59,12 @@
"markdown-it": "14.1.0",
"pathe": "^1.1.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^4.4.1",
"prettier": "^3.6.2",
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"three": "^0.172.0",
"vue-confetti-explosion": "^1.0.2",
"vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",

View File

@@ -0,0 +1,490 @@
<svg width="1120" height="116" viewBox="0 0 1120 116" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_9092_23263" fill="white">
<path
d="M460.704 -89.7906L418.995 -98.1936C415.591 -98.8731 412.786 -95.5784 414.023 -92.3281L429.187 -52.2419C430.194 -49.5896 433.662 -48.9476 435.498 -51.081L463.062 -83.0993C465.103 -85.4707 463.796 -89.1809 460.711 -89.7994L460.704 -89.7906Z" />
<path
d="M375.343 -163.469L389.792 -123.4C390.965 -120.129 388.124 -116.865 384.731 -117.612L342.9 -126.719C340.131 -127.327 338.986 -130.666 340.823 -132.799L368.386 -164.818C370.428 -167.189 374.284 -166.442 375.351 -163.478L375.343 -163.469Z" />
<path
d="M263.038 -121.213L274.559 -86.7584C275.398 -84.2678 277.512 -82.397 280.082 -81.8845L360.91 -65.7792C363.48 -65.2665 365.6 -63.437 366.485 -60.9671L394.325 16.7081C395.21 19.1779 397.363 21.0052 399.953 21.4584L435.687 27.8069C438.261 28.2613 440.866 27.3445 442.558 25.3785L498.485 -39.587C500.094 -41.456 500.626 -44.0395 499.897 -46.4205L490.583 -76.5881C490.392 -77.2139 489.995 -78.4479 489.551 -79.861C488.599 -82.849 484.745 -83.6556 482.737 -81.2863C481.871 -80.2812 481.082 -79.3643 480.596 -78.8001L430.703 -21.059C428.062 -17.991 423.057 -18.9759 421.68 -22.8226L395.993 -94.9369C395.064 -97.5367 392.837 -99.4584 390.141 -99.9873L315.122 -114.797C311.119 -115.594 309.418 -120.397 312.05 -123.473L361.73 -181.398C362.171 -181.909 362.966 -182.852 363.868 -183.917C365.861 -186.269 364.49 -189.942 361.417 -190.428C360.039 -190.649 358.786 -190.854 358.073 -190.963L326.901 -195.74C324.442 -196.111 321.976 -195.212 320.359 -193.335L264.433 -128.369C262.74 -126.403 262.211 -123.678 263.047 -121.205L263.038 -121.213Z" />
<path
d="M268.307 204.958L310.016 213.361C313.42 214.041 316.226 210.746 314.988 207.496L299.825 167.41C298.817 164.757 295.349 164.115 293.513 166.249L265.949 198.267C263.908 200.639 265.215 204.349 268.3 204.967L268.307 204.958Z" />
<path
d="M353.668 278.637L339.219 238.568C338.046 235.297 340.887 232.033 344.28 232.78L386.111 241.887C388.88 242.495 390.025 245.834 388.189 247.967L360.625 279.986C358.584 282.357 354.727 281.61 353.66 278.646L353.668 278.637Z" />
<path
d="M465.973 236.381L454.452 201.926C453.613 199.436 451.499 197.565 448.93 197.052L368.101 180.947C365.531 180.434 363.411 178.605 362.526 176.135L334.686 98.4597C333.801 95.9899 331.648 94.1626 329.058 93.7094L293.324 87.3609C290.75 86.9065 288.145 87.8232 286.453 89.7892L230.526 154.755C228.917 156.624 228.385 159.207 229.114 161.588L238.428 191.756C238.619 192.382 239.016 193.616 239.46 195.029C240.412 198.017 244.266 198.823 246.274 196.454C247.14 195.449 247.929 194.532 248.415 193.968L298.308 136.227C300.95 133.159 305.954 134.144 307.331 137.99L333.018 210.105C333.947 212.704 336.174 214.626 338.87 215.155L413.889 229.965C417.892 230.762 419.593 235.565 416.961 238.64L367.281 296.566C366.84 297.077 366.045 298.019 365.143 299.085C363.15 301.436 364.521 305.11 367.594 305.596C368.972 305.817 370.225 306.022 370.938 306.131L402.11 310.908C404.569 311.279 407.035 310.38 408.652 308.502L464.579 243.537C466.271 241.571 466.8 238.846 465.964 236.373L465.973 236.381Z" />
<path
d="M440.999 55.1022L399.29 46.6992C395.886 46.0197 393.08 49.3145 394.318 52.5647L409.482 92.6509C410.489 95.3032 413.957 95.9452 415.793 93.8118L443.357 61.7935C445.398 59.4221 444.091 55.7119 441.006 55.0934L440.999 55.1022Z" />
<path
d="M355.638 -18.5761L370.087 21.4924C371.26 24.7638 368.419 28.0279 365.026 27.2811L323.195 18.1735C320.426 17.5658 319.281 14.2267 321.117 12.0934L348.681 -19.925C350.723 -22.2964 354.579 -21.5491 355.646 -18.5849L355.638 -18.5761Z" />
<path
d="M243.333 23.6798L254.854 58.1344C255.693 60.6251 257.807 62.4958 260.376 63.0083L341.205 79.1136C343.775 79.6263 345.895 81.4558 346.78 83.9257L374.62 161.601C375.505 164.071 377.658 165.898 380.248 166.351L415.982 172.7C418.555 173.154 421.161 172.237 422.853 170.271L478.78 105.306C480.389 103.437 480.921 100.853 480.192 98.4723L470.878 68.3047C470.687 67.6789 470.29 66.4449 469.846 65.0319C468.894 62.0438 465.04 61.2373 463.032 63.6066C462.166 64.6116 461.377 65.5285 460.891 66.0927L410.998 123.834C408.357 126.902 403.352 125.917 401.975 122.07L376.288 49.9559C375.359 47.3562 373.132 45.4345 370.436 44.9055L295.417 30.0955C291.414 29.2991 289.713 24.496 292.345 21.4203L342.025 -36.5052C342.466 -37.0165 343.261 -37.9588 344.163 -39.0243C346.156 -41.3759 344.785 -45.049 341.712 -45.535C340.334 -45.7564 339.081 -45.9615 338.368 -46.07L307.196 -50.8473C304.737 -51.2181 302.271 -50.3195 300.654 -48.4417L244.727 16.5238C243.035 18.4898 242.506 21.2145 243.342 23.6875L243.333 23.6798Z" />
<path
d="M693.703 -89.7894L651.995 -98.1924C648.59 -98.8719 645.785 -95.5771 647.023 -92.3269L662.186 -52.2407C663.194 -49.5884 666.661 -48.9464 668.498 -51.0798L696.061 -83.0981C698.103 -85.4695 696.795 -89.1797 693.711 -89.7982L693.703 -89.7894Z" />
<path
d="M608.342 -163.468L622.791 -123.399C623.964 -120.128 621.123 -116.864 617.73 -117.611L575.899 -126.718C573.13 -127.326 571.985 -130.665 573.822 -132.798L601.385 -164.817C603.427 -167.188 607.283 -166.441 608.35 -163.477L608.342 -163.468Z" />
<path
d="M496.037 -121.212L507.559 -86.7572C508.397 -84.2665 510.511 -82.3958 513.081 -81.8833L593.909 -65.778C596.48 -65.2653 598.599 -63.4358 599.484 -60.9659L627.324 16.7093C628.209 19.1791 630.362 21.0064 632.952 21.4596L668.686 27.8081C671.26 28.2625 673.865 27.3458 675.558 25.3798L731.485 -39.5858C733.094 -41.4548 733.626 -44.0383 732.897 -46.4193L723.582 -76.5869C723.392 -77.2127 722.995 -78.4467 722.55 -79.8598C721.599 -82.8478 717.745 -83.6543 715.736 -81.285C714.871 -80.28 714.081 -79.3631 713.596 -78.7989L663.702 -21.0578C661.061 -17.9897 656.056 -18.9746 654.68 -22.8213L628.992 -94.9357C628.063 -97.5354 625.837 -99.4571 623.141 -99.9861L548.121 -114.796C544.118 -115.593 542.417 -120.396 545.05 -123.471L594.73 -181.397C595.17 -181.908 595.966 -182.85 596.867 -183.916C598.861 -186.267 597.49 -189.941 594.416 -190.427C593.038 -190.648 591.785 -190.853 591.072 -190.962L559.901 -195.739C557.441 -196.11 554.975 -195.211 553.359 -193.333L497.432 -128.368C495.739 -126.402 495.211 -123.677 496.046 -121.204L496.037 -121.212Z" />
<path
d="M501.308 204.961L543.017 213.364C546.421 214.043 549.227 210.748 547.989 207.498L532.826 167.412C531.818 164.76 528.35 164.118 526.514 166.251L498.95 198.269C496.909 200.641 498.216 204.351 501.301 204.969L501.308 204.961Z" />
<path
d="M586.669 278.639L572.221 238.57C571.047 235.299 573.888 232.035 577.282 232.782L619.112 241.889C621.882 242.497 623.026 245.836 621.19 247.969L593.626 279.988C591.585 282.359 587.728 281.612 586.662 278.648L586.669 278.639Z" />
<path
d="M698.974 236.383L687.453 201.928C686.615 199.438 684.501 197.567 681.931 197.054L601.102 180.949C598.532 180.436 596.412 178.607 595.527 176.137L567.687 98.4619C566.802 95.9921 564.649 94.1648 562.059 93.7116L526.325 87.3631C523.752 86.9087 521.146 87.8254 519.454 89.7914L463.527 154.757C461.918 156.626 461.386 159.209 462.115 161.59L471.429 191.758C471.62 192.384 472.017 193.618 472.461 195.031C473.413 198.019 477.267 198.826 479.276 196.456C480.141 195.451 480.93 194.534 481.416 193.97L531.309 136.229C533.951 133.161 538.955 134.146 540.332 137.993L566.02 210.107C566.948 212.707 569.175 214.628 571.871 215.157L646.89 229.967C650.893 230.764 652.594 235.567 649.962 238.643L600.282 296.568C599.842 297.079 599.046 298.022 598.144 299.087C596.151 301.439 597.522 305.112 600.595 305.598C601.973 305.819 603.226 306.024 603.939 306.133L635.111 310.91C637.57 311.281 640.036 310.382 641.653 308.504L697.58 243.539C699.272 241.573 699.801 238.848 698.965 236.375L698.974 236.383Z" />
<path
d="M674 55.1032L632.291 46.7001C628.887 46.0206 626.082 49.3155 627.319 52.5657L642.483 92.6519C643.49 95.3041 646.958 95.9462 648.795 93.8128L676.358 61.7945C678.4 59.4231 677.092 55.7129 674.008 55.0944L674 55.1032Z" />
<path
d="M588.639 -18.5751L603.088 21.4934C604.261 24.7648 601.42 28.0289 598.027 27.2821L556.196 18.1745C553.427 17.5667 552.282 14.2277 554.119 12.0943L581.682 -19.924C583.724 -22.2954 587.58 -21.5481 588.647 -18.584L588.639 -18.5751Z" />
<path
d="M476.334 23.6808L487.856 58.1354C488.694 60.626 490.808 62.4968 493.378 63.0093L574.206 79.1146C576.776 79.6273 578.896 81.4568 579.781 83.9267L607.621 161.602C608.506 164.072 610.659 165.899 613.249 166.352L648.983 172.701C651.557 173.155 654.162 172.238 655.855 170.272L711.781 105.307C713.39 103.438 713.922 100.854 713.193 98.4733L703.879 68.3057C703.688 67.6799 703.291 66.4459 702.847 65.0328C701.896 62.0448 698.041 61.2382 696.033 63.6075C695.168 64.6126 694.378 65.5295 693.893 66.0937L643.999 123.835C641.358 126.903 636.353 125.918 634.977 122.071L609.289 49.9569C608.36 47.3571 606.134 45.4354 603.438 44.9065L528.418 30.0964C524.415 29.3 522.714 24.4969 525.346 21.4212L575.027 -36.5042C575.467 -37.0156 576.262 -37.9578 577.164 -39.0234C579.158 -41.3749 577.786 -45.048 574.713 -45.5341C573.335 -45.7554 572.082 -45.9606 571.369 -46.069L540.198 -50.8463C537.738 -51.2171 535.272 -50.3185 533.656 -48.4407L477.729 16.5248C476.036 18.4908 475.508 21.2154 476.343 23.6885L476.334 23.6808Z" />
<path
d="M931.704 -89.7906L889.995 -98.1936C886.591 -98.8731 883.785 -95.5784 885.023 -92.3281L900.186 -52.2419C901.194 -49.5896 904.662 -48.9476 906.498 -51.081L934.062 -83.0993C936.103 -85.4707 934.796 -89.1809 931.711 -89.7994L931.704 -89.7906Z" />
<path
d="M846.343 -163.469L860.791 -123.4C861.964 -120.129 859.124 -116.865 855.73 -117.612L813.9 -126.719C811.13 -127.327 809.986 -130.666 811.822 -132.799L839.386 -164.818C841.427 -167.189 845.284 -166.442 846.35 -163.478L846.343 -163.469Z" />
<path
d="M734.038 -121.213L745.559 -86.7584C746.397 -84.2678 748.511 -82.397 751.081 -81.8845L831.91 -65.7792C834.48 -65.2665 836.6 -63.437 837.485 -60.9671L865.325 16.7081C866.21 19.1779 868.362 21.0052 870.953 21.4584L906.687 27.8069C909.26 28.2613 911.866 27.3445 913.558 25.3785L969.485 -39.587C971.094 -41.456 971.626 -44.0395 970.897 -46.4205L961.583 -76.5881C961.392 -77.2139 960.995 -78.4479 960.551 -79.861C959.599 -82.849 955.745 -83.6556 953.736 -81.2863C952.871 -80.2812 952.082 -79.3643 951.596 -78.8001L901.703 -21.059C899.061 -17.991 894.057 -18.9759 892.68 -22.8226L866.992 -94.9369C866.064 -97.5367 863.837 -99.4584 861.141 -99.9873L786.122 -114.797C782.119 -115.594 780.418 -120.397 783.05 -123.473L832.73 -181.398C833.17 -181.909 833.966 -182.852 834.868 -183.917C836.861 -186.269 835.49 -189.942 832.417 -190.428C831.039 -190.649 829.786 -190.854 829.073 -190.963L797.901 -195.74C795.442 -196.111 792.976 -195.212 791.359 -193.335L735.432 -128.369C733.74 -126.403 733.211 -123.678 734.047 -121.205L734.038 -121.213Z" />
<path
d="M739.307 204.957L781.016 213.36C784.42 214.04 787.225 210.745 785.988 207.495L770.824 167.409C769.817 164.756 766.349 164.114 764.513 166.248L736.949 198.266C734.907 200.638 736.215 204.348 739.299 204.966L739.307 204.957Z" />
<path
d="M824.668 278.636L810.219 238.567C809.046 235.296 811.887 232.032 815.28 232.779L857.111 241.886C859.88 242.494 861.025 245.833 859.188 247.966L831.625 279.985C829.583 282.356 825.727 281.609 824.66 278.645L824.668 278.636Z" />
<path
d="M936.973 236.38L925.452 201.925C924.613 199.435 922.499 197.564 919.929 197.051L839.101 180.946C836.531 180.433 834.411 178.604 833.526 176.134L805.686 98.4587C804.801 95.9889 802.648 94.1616 800.058 93.7084L764.324 87.3599C761.75 86.9055 759.145 87.8223 757.452 89.7883L701.526 154.754C699.917 156.623 699.385 159.206 700.114 161.587L709.428 191.755C709.619 192.381 710.016 193.615 710.46 195.028C711.411 198.016 715.266 198.822 717.274 196.453C718.139 195.448 718.929 194.531 719.415 193.967L769.308 136.226C771.949 133.158 776.954 134.143 778.331 137.989L804.018 210.104C804.947 212.703 807.174 214.625 809.87 215.154L884.889 229.964C888.892 230.761 890.593 235.564 887.961 238.639L838.281 296.565C837.84 297.076 837.045 298.018 836.143 299.084C834.15 301.436 835.521 305.109 838.594 305.595C839.972 305.816 841.225 306.021 841.938 306.13L873.109 310.907C875.569 311.278 878.035 310.379 879.652 308.501L935.578 243.536C937.271 241.57 937.8 238.845 936.964 236.372L936.973 236.38Z" />
<path
d="M911.999 55.102L870.29 46.6989C866.886 46.0194 864.08 49.3142 865.318 52.5645L880.481 92.6507C881.489 95.3029 884.957 95.9449 886.793 93.8116L914.357 61.7933C916.398 59.4218 915.091 55.7117 912.006 55.0932L911.999 55.102Z" />
<path
d="M826.638 -18.5764L841.086 21.4922C842.259 24.7636 839.418 28.0277 836.025 27.2808L794.195 18.1733C791.425 17.5655 790.281 14.2265 792.117 12.0931L819.681 -19.9252C821.722 -22.2966 825.579 -21.5494 826.645 -18.5852L826.638 -18.5764Z" />
<path
d="M714.333 23.6796L725.854 58.1342C726.692 60.6248 728.806 62.4956 731.376 63.0081L812.205 79.1134C814.775 79.6261 816.895 81.4556 817.78 83.9254L845.62 161.601C846.505 164.07 848.657 165.898 851.248 166.351L886.981 172.699C889.555 173.154 892.161 172.237 893.853 170.271L949.78 105.306C951.389 103.437 951.921 100.853 951.192 98.4721L941.878 68.3045C941.687 67.6787 941.29 66.4447 940.846 65.0316C939.894 62.0436 936.04 61.237 934.031 63.6063C933.166 64.6113 932.377 65.5283 931.891 66.0925L881.997 123.834C879.356 126.902 874.352 125.917 872.975 122.07L847.287 49.9557C846.359 47.3559 844.132 45.4342 841.436 44.9053L766.417 30.0952C762.414 29.2988 760.713 24.4957 763.345 21.42L813.025 -36.5054C813.465 -37.0168 814.261 -37.959 815.163 -39.0246C817.156 -41.3761 815.785 -45.0493 812.712 -45.5353C811.333 -45.7567 810.081 -45.9618 809.368 -46.0702L778.196 -50.8475C775.737 -51.2183 773.271 -50.3197 771.654 -48.4419L715.727 16.5236C714.035 18.4896 713.506 21.2142 714.342 23.6873L714.333 23.6796Z" />
<path
d="M1164.7 -89.7906L1122.99 -98.1936C1119.59 -98.8731 1116.78 -95.5784 1118.02 -92.3281L1133.19 -52.2419C1134.19 -49.5896 1137.66 -48.9476 1139.5 -51.081L1167.06 -83.0993C1169.1 -85.4707 1167.79 -89.1809 1164.71 -89.7994L1164.7 -89.7906Z" />
<path
d="M1079.34 -163.469L1093.79 -123.4C1094.96 -120.129 1092.12 -116.865 1088.73 -117.612L1046.9 -126.719C1044.13 -127.327 1042.98 -130.666 1044.82 -132.799L1072.39 -164.818C1074.43 -167.189 1078.28 -166.442 1079.35 -163.478L1079.34 -163.469Z" />
<path
d="M967.037 -121.213L978.558 -86.7584C979.397 -84.2678 981.511 -82.397 984.081 -81.8845L1064.91 -65.7792C1067.48 -65.2665 1069.6 -63.437 1070.48 -60.9671L1098.32 16.7081C1099.21 19.1779 1101.36 21.0052 1103.95 21.4584L1139.69 27.8069C1142.26 28.2613 1144.87 27.3445 1146.56 25.3785L1202.48 -39.587C1204.09 -41.456 1204.63 -44.0395 1203.9 -46.4205L1194.58 -76.5881C1194.39 -77.2139 1193.99 -78.4479 1193.55 -79.861C1192.6 -82.849 1188.74 -83.6556 1186.74 -81.2863C1185.87 -80.2812 1185.08 -79.3643 1184.6 -78.8001L1134.7 -21.059C1132.06 -17.991 1127.06 -18.9759 1125.68 -22.8226L1099.99 -94.9369C1099.06 -97.5367 1096.84 -99.4584 1094.14 -99.9873L1019.12 -114.797C1015.12 -115.594 1013.42 -120.397 1016.05 -123.473L1065.73 -181.398C1066.17 -181.909 1066.97 -182.852 1067.87 -183.917C1069.86 -186.269 1068.49 -189.942 1065.42 -190.428C1064.04 -190.649 1062.79 -190.854 1062.07 -190.963L1030.9 -195.74C1028.44 -196.111 1025.97 -195.212 1024.36 -193.335L968.432 -128.369C966.739 -126.403 966.21 -123.678 967.046 -121.205L967.037 -121.213Z" />
<path
d="M972.308 204.959L1014.02 213.362C1017.42 214.042 1020.23 210.747 1018.99 207.497L1003.83 167.411C1002.82 164.758 999.35 164.116 997.514 166.25L969.95 198.268C967.908 200.639 969.216 204.35 972.3 204.968L972.308 204.959Z" />
<path
d="M1057.67 278.638L1043.22 238.569C1042.05 235.298 1044.89 232.034 1048.28 232.78L1090.11 241.888C1092.88 242.496 1094.03 245.835 1092.19 247.968L1064.63 279.987C1062.58 282.358 1058.73 281.611 1057.66 278.647L1057.67 278.638Z" />
<path
d="M1169.97 236.382L1158.45 201.927C1157.61 199.437 1155.5 197.566 1152.93 197.053L1072.1 180.948C1069.53 180.435 1067.41 178.606 1066.53 176.136L1038.69 98.4607C1037.8 95.9908 1035.65 94.1636 1033.06 93.7104L997.325 87.3618C994.751 86.9075 992.146 87.8242 990.453 89.7902L934.527 154.756C932.918 156.625 932.386 159.208 933.115 161.589L942.429 191.757C942.62 192.383 943.017 193.617 943.461 195.03C944.413 198.018 948.267 198.824 950.275 196.455C951.14 195.45 951.93 194.533 952.416 193.969L1002.31 136.228C1004.95 133.16 1009.95 134.145 1011.33 137.991L1037.02 210.106C1037.95 212.705 1040.17 214.627 1042.87 215.156L1117.89 229.966C1121.89 230.763 1123.59 235.566 1120.96 238.641L1071.28 296.567C1070.84 297.078 1070.05 298.02 1069.14 299.086C1067.15 301.437 1068.52 305.111 1071.6 305.597C1072.97 305.818 1074.23 306.023 1074.94 306.132L1106.11 310.909C1108.57 311.28 1111.04 310.381 1112.65 308.503L1168.58 243.538C1170.27 241.572 1170.8 238.847 1169.96 236.374L1169.97 236.382Z" />
<path
d="M1145 55.102L1103.29 46.6989C1099.89 46.0194 1097.08 49.3142 1098.32 52.5645L1113.48 92.6507C1114.49 95.3029 1117.96 95.9449 1119.79 93.8116L1147.36 61.7933C1149.4 59.4218 1148.09 55.7117 1145.01 55.0932L1145 55.102Z" />
<path
d="M1059.64 -18.5764L1074.09 21.4922C1075.26 24.7636 1072.42 28.0277 1069.03 27.2808L1027.2 18.1733C1024.43 17.5655 1023.28 14.2265 1025.12 12.0931L1052.68 -19.9252C1054.72 -22.2966 1058.58 -21.5494 1059.65 -18.5852L1059.64 -18.5764Z" />
<path
d="M947.334 23.6796L958.855 58.1342C959.694 60.6248 961.808 62.4956 964.377 63.0081L1045.21 79.1134C1047.78 79.6261 1049.9 81.4556 1050.78 83.9254L1078.62 161.601C1079.51 164.07 1081.66 165.898 1084.25 166.351L1119.98 172.699C1122.56 173.154 1125.16 172.237 1126.85 170.271L1182.78 105.306C1184.39 103.437 1184.92 100.853 1184.19 98.4721L1174.88 68.3045C1174.69 67.6787 1174.29 66.4447 1173.85 65.0316C1172.9 62.0436 1169.04 61.237 1167.03 63.6063C1166.17 64.6113 1165.38 65.5283 1164.89 66.0925L1115 123.834C1112.36 126.902 1107.35 125.917 1105.98 122.07L1080.29 49.9557C1079.36 47.3559 1077.13 45.4342 1074.44 44.9053L999.418 30.0952C995.415 29.2988 993.714 24.4957 996.346 21.42L1046.03 -36.5054C1046.47 -37.0168 1047.26 -37.959 1048.16 -39.0246C1050.16 -41.3761 1048.79 -45.0493 1045.71 -45.5353C1044.33 -45.7567 1043.08 -45.9618 1042.37 -46.0702L1011.2 -50.8475C1008.74 -51.2183 1006.27 -50.3197 1004.66 -48.4419L948.728 16.5236C947.036 18.4896 946.507 21.2142 947.343 23.6873L947.334 23.6796Z" />
<path
d="M-10.2954 -89.7842L-52.0041 -98.1873C-55.4084 -98.8668 -58.2138 -95.572 -56.976 -92.3217L-41.8127 -52.2355C-40.805 -49.5833 -37.3374 -48.9413 -35.5009 -51.0746L-7.93733 -83.093C-5.89584 -85.4644 -7.20342 -89.1745 -10.2878 -89.7931L-10.2954 -89.7842Z" />
<path
d="M-95.6563 -163.463L-81.2076 -123.394C-80.0346 -120.123 -82.8755 -116.859 -86.2686 -117.605L-128.099 -126.713C-130.869 -127.321 -132.013 -130.66 -130.177 -132.793L-102.613 -164.811C-100.572 -167.183 -96.7151 -166.436 -95.6487 -163.471L-95.6563 -163.463Z" />
<path
d="M-207.961 -121.207L-196.44 -86.7521C-195.602 -84.2614 -193.488 -82.3906 -190.918 -81.8782L-110.089 -65.7728C-107.519 -65.2602 -105.399 -63.4306 -104.514 -60.9608L-76.6744 16.7144C-75.7891 19.1843 -73.6365 21.0115 -71.0462 21.4648L-35.3125 27.8132C-32.7387 28.2676 -30.1333 27.3509 -28.4408 25.3849L27.486 -39.5807C29.0949 -41.4496 29.627 -44.0332 28.898 -46.4142L19.5838 -76.5818C19.3929 -77.2076 18.996 -78.4415 18.5516 -79.8546C17.6002 -82.8427 13.746 -83.6492 11.7374 -81.2799C10.8722 -80.2749 10.0828 -79.358 9.59708 -78.7937L-40.2965 -21.0526C-42.9377 -17.9846 -47.9423 -18.9695 -49.3189 -22.8162L-75.0067 -94.9306C-75.9355 -97.5303 -78.1619 -99.452 -80.8578 -99.9809L-155.877 -114.791C-159.88 -115.587 -161.581 -120.391 -158.949 -123.466L-109.269 -181.392C-108.829 -181.903 -108.033 -182.845 -107.131 -183.911C-105.138 -186.262 -106.509 -189.935 -109.582 -190.422C-110.961 -190.643 -112.213 -190.848 -112.926 -190.956L-144.098 -195.734C-146.557 -196.105 -149.023 -195.206 -150.64 -193.328L-206.567 -128.363C-208.259 -126.397 -208.788 -123.672 -207.952 -121.199L-207.961 -121.207Z" />
<path
d="M-202.692 204.964L-160.984 213.367C-157.579 214.046 -154.774 210.752 -156.012 207.501L-171.175 167.415C-172.183 164.763 -175.65 164.121 -177.487 166.254L-205.05 198.272C-207.092 200.644 -205.784 204.354 -202.7 204.973L-202.692 204.964Z" />
<path
d="M-117.331 278.642L-131.78 238.574C-132.953 235.302 -130.112 232.038 -126.719 232.785L-84.8883 241.892C-82.119 242.5 -80.9743 245.839 -82.8109 247.973L-110.374 279.991C-112.416 282.362 -116.273 281.615 -117.339 278.651L-117.331 278.642Z" />
<path
d="M-5.0264 236.386L-16.5478 201.932C-17.386 199.441 -19.5 197.57 -22.0699 197.058L-102.898 180.952C-105.469 180.44 -107.588 178.61 -108.474 176.14L-136.313 98.4651C-137.198 95.9952 -139.351 94.168 -141.941 93.7148L-177.675 87.3662C-180.249 86.9119 -182.854 87.8286 -184.547 89.7946L-240.474 154.76C-242.083 156.629 -242.615 159.213 -241.886 161.594L-232.571 191.761C-232.381 192.387 -231.984 193.621 -231.539 195.034C-230.588 198.022 -226.734 198.829 -224.725 196.459C-223.86 195.454 -223.07 194.537 -222.585 193.973L-172.691 136.232C-170.05 133.164 -165.045 134.149 -163.669 137.996L-137.981 210.11C-137.052 212.71 -134.826 214.631 -132.13 215.16L-57.1103 229.971C-53.1076 230.767 -51.4064 235.57 -54.0387 238.646L-103.719 296.571C-104.159 297.083 -104.955 298.025 -105.856 299.09C-107.85 301.442 -106.479 305.115 -103.405 305.601C-102.027 305.822 -100.775 306.028 -100.061 306.136L-68.8898 310.913C-66.4306 311.284 -63.9642 310.385 -62.3477 308.508L-6.42088 243.542C-4.72842 241.576 -4.19972 238.852 -5.03531 236.378L-5.0264 236.386Z" />
<path
d="M-30.0004 55.1083L-71.7092 46.7053C-75.1135 46.0258 -77.9188 49.3206 -76.6811 52.5708L-61.5178 92.657C-60.5101 95.3093 -57.0425 95.9513 -55.206 93.8179L-27.6424 61.7996C-25.6009 59.4282 -26.9085 55.7181 -29.9929 55.0995L-30.0004 55.1083Z" />
<path
d="M-115.361 -18.57L-100.913 21.4985C-99.7397 24.7699 -102.581 28.034 -105.974 27.2872L-147.804 18.1796C-150.574 17.5719 -151.718 14.2328 -149.882 12.0995L-122.318 -19.9189C-120.277 -22.2903 -116.42 -21.543 -115.354 -18.5788L-115.361 -18.57Z" />
<path
d="M-227.666 23.6859L-216.145 58.1405C-215.307 60.6312 -213.193 62.5019 -210.623 63.0144L-129.794 79.1198C-127.224 79.6324 -125.104 81.4619 -124.219 83.9318L-96.3795 161.607C-95.4942 164.077 -93.3416 165.904 -90.7513 166.357L-55.0176 172.706C-52.4438 173.16 -49.8384 172.243 -48.1459 170.277L7.78091 105.312C9.38986 103.443 9.9219 100.859 9.19295 98.4784L-0.121262 68.3108C-0.312151 67.685 -0.709083 66.451 -1.15346 65.038C-2.10493 62.0499 -5.95909 61.2434 -7.96773 63.6127C-8.83292 64.6177 -9.62228 65.5346 -10.108 66.0988L-60.0016 123.84C-62.6428 126.908 -67.6474 125.923 -69.0239 122.076L-94.7117 49.962C-95.6405 47.3623 -97.8669 45.4406 -100.563 44.9116L-175.582 30.1016C-179.585 29.3052 -181.286 24.5021 -178.654 21.4264L-128.974 -36.4991C-128.534 -37.0104 -127.738 -37.9527 -126.836 -39.0182C-124.843 -41.3698 -126.214 -45.0429 -129.288 -45.5289C-130.666 -45.7503 -131.918 -45.9554 -132.631 -46.0639L-163.803 -50.8412C-166.262 -51.212 -168.728 -50.3134 -170.345 -48.4356L-226.272 16.5299C-227.964 18.4959 -228.493 21.2206 -227.657 23.6936L-227.666 23.6859Z" />
<path
d="M222.706 -89.7842L180.997 -98.1873C177.593 -98.8668 174.787 -95.572 176.025 -92.3217L191.188 -52.2355C192.196 -49.5833 195.663 -48.9413 197.5 -51.0746L225.064 -83.093C227.105 -85.4644 225.798 -89.1745 222.713 -89.7931L222.706 -89.7842Z" />
<path
d="M137.345 -163.463L151.793 -123.394C152.966 -120.123 150.125 -116.859 146.732 -117.605L104.902 -126.713C102.132 -127.321 100.988 -130.66 102.824 -132.793L130.388 -164.811C132.429 -167.183 136.286 -166.436 137.352 -163.471L137.345 -163.463Z" />
<path
d="M25.0397 -121.207L36.5611 -86.7521C37.3993 -84.2614 39.5133 -82.3906 42.0832 -81.8782L122.912 -65.7728C125.482 -65.2602 127.602 -63.4306 128.487 -60.9608L156.327 16.7144C157.212 19.1843 159.364 21.0115 161.955 21.4648L197.688 27.8132C200.262 28.2676 202.868 27.3509 204.56 25.3849L260.487 -39.5807C262.096 -41.4496 262.628 -44.0332 261.899 -46.4142L252.585 -76.5818C252.394 -77.2076 251.997 -78.4415 251.553 -79.8546C250.601 -82.8427 246.747 -83.6492 244.738 -81.2799C243.873 -80.2749 243.084 -79.358 242.598 -78.7937L192.704 -21.0526C190.063 -17.9846 185.059 -18.9695 183.682 -22.8162L157.994 -94.9306C157.066 -97.5303 154.839 -99.452 152.143 -99.9809L77.1236 -114.791C73.1209 -115.587 71.4197 -120.391 74.052 -123.466L123.732 -181.392C124.172 -181.903 124.968 -182.845 125.87 -183.911C127.863 -186.262 126.492 -189.935 123.418 -190.422C122.04 -190.643 120.788 -190.848 120.075 -190.956L88.9031 -195.734C86.4439 -196.105 83.9776 -195.206 82.361 -193.328L26.4342 -128.363C24.7417 -126.397 24.213 -123.672 25.0486 -121.199L25.0397 -121.207Z" />
<path
d="M30.3106 204.966L72.0193 213.369C75.4237 214.048 78.229 210.753 76.9913 207.503L61.828 167.417C60.8203 164.765 57.3527 164.123 55.5162 166.256L27.9526 198.274C25.9111 200.646 27.2187 204.356 30.303 204.975L30.3106 204.966Z" />
<path
d="M115.672 278.644L101.223 238.576C100.05 235.304 102.891 232.04 106.284 232.787L148.115 241.894C150.884 242.502 152.029 245.841 150.192 247.975L122.628 279.993C120.587 282.364 116.73 281.617 115.664 278.653L115.672 278.644Z" />
<path
d="M227.977 236.388L216.455 201.934C215.617 199.443 213.503 197.572 210.933 197.06L130.104 180.954C127.534 180.442 125.415 178.612 124.529 176.142L96.6897 98.467C95.8044 95.9972 93.6518 94.17 91.0615 93.7167L55.3278 87.3682C52.754 86.9138 50.1486 87.8306 48.4561 89.7966L-7.47073 154.762C-9.07968 156.631 -9.61172 159.215 -8.88277 161.596L0.431442 191.763C0.622332 192.389 1.01926 193.623 1.46364 195.036C2.41511 198.024 6.26927 198.831 8.27791 196.461C9.1431 195.456 9.93247 194.539 10.4182 193.975L60.3118 136.234C62.953 133.166 67.9575 134.151 69.3341 137.998L95.0219 210.112C95.9507 212.712 98.1771 214.633 100.873 215.162L175.893 229.972C179.895 230.769 181.597 235.572 178.964 238.648L129.284 296.573C128.844 297.084 128.048 298.027 127.147 299.092C125.153 301.444 126.524 305.117 129.598 305.603C130.976 305.824 132.228 306.029 132.942 306.138L164.113 310.915C166.572 311.286 169.039 310.387 170.655 308.51L226.582 243.544C228.274 241.578 228.803 238.853 227.968 236.38L227.977 236.388Z" />
<path
d="M203.002 55.1083L161.294 46.7053C157.889 46.0258 155.084 49.3206 156.322 52.5708L171.485 92.657C172.493 95.3093 175.96 95.9513 177.797 93.8179L205.361 61.7996C207.402 59.4282 206.094 55.7181 203.01 55.0995L203.002 55.1083Z" />
<path
d="M117.642 -18.57L132.09 21.4985C133.263 24.7699 130.422 28.034 127.029 27.2872L85.1985 18.1796C82.4292 17.5719 81.2845 14.2328 83.121 12.0995L110.685 -19.9189C112.726 -22.2903 116.583 -21.543 117.649 -18.5788L117.642 -18.57Z" />
<path
d="M5.33659 23.6859L16.858 58.1405C17.6961 60.6312 19.8102 62.5019 22.3801 63.0144L103.209 79.1198C105.779 79.6324 107.898 81.4619 108.784 83.9318L136.623 161.607C137.509 164.077 139.661 165.904 142.252 166.357L177.985 172.706C180.559 173.16 183.165 172.243 184.857 170.277L240.784 105.312C242.393 103.443 242.925 100.859 242.196 98.4784L232.882 68.3108C232.691 67.685 232.294 66.451 231.849 65.038C230.898 62.0499 227.044 61.2434 225.035 63.6127C224.17 64.6177 223.381 65.5346 222.895 66.0988L173.001 123.84C170.36 126.908 165.356 125.923 163.979 122.076L138.291 49.962C137.362 47.3623 135.136 45.4406 132.44 44.9116L57.4205 30.1016C53.4178 29.3052 51.7166 24.5021 54.3488 21.4264L104.029 -36.4991C104.469 -37.0104 105.265 -37.9527 106.167 -39.0182C108.16 -41.3698 106.789 -45.0429 103.715 -45.5289C102.337 -45.7503 101.085 -45.9554 100.372 -46.0639L69.2 -50.8412C66.7408 -51.212 64.2744 -50.3134 62.6579 -48.4356L6.73106 16.5299C5.0386 18.4959 4.5099 21.2206 5.34549 23.6936L5.33659 23.6859Z" />
</mask>
<path
d="M460.704 -89.7906L418.995 -98.1936C415.591 -98.8731 412.786 -95.5784 414.023 -92.3281L429.187 -52.2419C430.194 -49.5896 433.662 -48.9476 435.498 -51.081L463.062 -83.0993C465.103 -85.4707 463.796 -89.1809 460.711 -89.7994L460.704 -89.7906Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M375.343 -163.469L389.792 -123.4C390.965 -120.129 388.124 -116.865 384.731 -117.612L342.9 -126.719C340.131 -127.327 338.986 -130.666 340.823 -132.799L368.386 -164.818C370.428 -167.189 374.284 -166.442 375.351 -163.478L375.343 -163.469Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M263.038 -121.213L274.559 -86.7584C275.398 -84.2678 277.512 -82.397 280.082 -81.8845L360.91 -65.7792C363.48 -65.2665 365.6 -63.437 366.485 -60.9671L394.325 16.7081C395.21 19.1779 397.363 21.0052 399.953 21.4584L435.687 27.8069C438.261 28.2613 440.866 27.3445 442.558 25.3785L498.485 -39.587C500.094 -41.456 500.626 -44.0395 499.897 -46.4205L490.583 -76.5881C490.392 -77.2139 489.995 -78.4479 489.551 -79.861C488.599 -82.849 484.745 -83.6556 482.737 -81.2863C481.871 -80.2812 481.082 -79.3643 480.596 -78.8001L430.703 -21.059C428.062 -17.991 423.057 -18.9759 421.68 -22.8226L395.993 -94.9369C395.064 -97.5367 392.837 -99.4584 390.141 -99.9873L315.122 -114.797C311.119 -115.594 309.418 -120.397 312.05 -123.473L361.73 -181.398C362.171 -181.909 362.966 -182.852 363.868 -183.917C365.861 -186.269 364.49 -189.942 361.417 -190.428C360.039 -190.649 358.786 -190.854 358.073 -190.963L326.901 -195.74C324.442 -196.111 321.976 -195.212 320.359 -193.335L264.433 -128.369C262.74 -126.403 262.211 -123.678 263.047 -121.205L263.038 -121.213Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M268.307 204.958L310.016 213.361C313.42 214.041 316.226 210.746 314.988 207.496L299.825 167.41C298.817 164.757 295.349 164.115 293.513 166.249L265.949 198.267C263.908 200.639 265.215 204.349 268.3 204.967L268.307 204.958Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M353.668 278.637L339.219 238.568C338.046 235.297 340.887 232.033 344.28 232.78L386.111 241.887C388.88 242.495 390.025 245.834 388.189 247.967L360.625 279.986C358.584 282.357 354.727 281.61 353.66 278.646L353.668 278.637Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M465.973 236.381L454.452 201.926C453.613 199.436 451.499 197.565 448.93 197.052L368.101 180.947C365.531 180.434 363.411 178.605 362.526 176.135L334.686 98.4597C333.801 95.9899 331.648 94.1626 329.058 93.7094L293.324 87.3609C290.75 86.9065 288.145 87.8232 286.453 89.7892L230.526 154.755C228.917 156.624 228.385 159.207 229.114 161.588L238.428 191.756C238.619 192.382 239.016 193.616 239.46 195.029C240.412 198.017 244.266 198.823 246.274 196.454C247.14 195.449 247.929 194.532 248.415 193.968L298.308 136.227C300.95 133.159 305.954 134.144 307.331 137.99L333.018 210.105C333.947 212.704 336.174 214.626 338.87 215.155L413.889 229.965C417.892 230.762 419.593 235.565 416.961 238.64L367.281 296.566C366.84 297.077 366.045 298.019 365.143 299.085C363.15 301.436 364.521 305.11 367.594 305.596C368.972 305.817 370.225 306.022 370.938 306.131L402.11 310.908C404.569 311.279 407.035 310.38 408.652 308.502L464.579 243.537C466.271 241.571 466.8 238.846 465.964 236.373L465.973 236.381Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M440.999 55.1022L399.29 46.6992C395.886 46.0197 393.08 49.3145 394.318 52.5647L409.482 92.6509C410.489 95.3032 413.957 95.9452 415.793 93.8118L443.357 61.7935C445.398 59.4221 444.091 55.7119 441.006 55.0934L440.999 55.1022Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M355.638 -18.5761L370.087 21.4924C371.26 24.7638 368.419 28.0279 365.026 27.2811L323.195 18.1735C320.426 17.5658 319.281 14.2267 321.117 12.0934L348.681 -19.925C350.723 -22.2964 354.579 -21.5491 355.646 -18.5849L355.638 -18.5761Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M243.333 23.6798L254.854 58.1344C255.693 60.6251 257.807 62.4958 260.376 63.0083L341.205 79.1136C343.775 79.6263 345.895 81.4558 346.78 83.9257L374.62 161.601C375.505 164.071 377.658 165.898 380.248 166.351L415.982 172.7C418.555 173.154 421.161 172.237 422.853 170.271L478.78 105.306C480.389 103.437 480.921 100.853 480.192 98.4723L470.878 68.3047C470.687 67.6789 470.29 66.4449 469.846 65.0319C468.894 62.0438 465.04 61.2373 463.032 63.6066C462.166 64.6116 461.377 65.5285 460.891 66.0927L410.998 123.834C408.357 126.902 403.352 125.917 401.975 122.07L376.288 49.9559C375.359 47.3562 373.132 45.4345 370.436 44.9055L295.417 30.0955C291.414 29.2991 289.713 24.496 292.345 21.4203L342.025 -36.5052C342.466 -37.0165 343.261 -37.9588 344.163 -39.0243C346.156 -41.3759 344.785 -45.049 341.712 -45.535C340.334 -45.7564 339.081 -45.9615 338.368 -46.07L307.196 -50.8473C304.737 -51.2181 302.271 -50.3195 300.654 -48.4417L244.727 16.5238C243.035 18.4898 242.506 21.2145 243.342 23.6875L243.333 23.6798Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M693.703 -89.7894L651.995 -98.1924C648.59 -98.8719 645.785 -95.5771 647.023 -92.3269L662.186 -52.2407C663.194 -49.5884 666.661 -48.9464 668.498 -51.0798L696.061 -83.0981C698.103 -85.4695 696.795 -89.1797 693.711 -89.7982L693.703 -89.7894Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M608.342 -163.468L622.791 -123.399C623.964 -120.128 621.123 -116.864 617.73 -117.611L575.899 -126.718C573.13 -127.326 571.985 -130.665 573.822 -132.798L601.385 -164.817C603.427 -167.188 607.283 -166.441 608.35 -163.477L608.342 -163.468Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M496.037 -121.212L507.559 -86.7572C508.397 -84.2665 510.511 -82.3958 513.081 -81.8833L593.909 -65.778C596.48 -65.2653 598.599 -63.4358 599.484 -60.9659L627.324 16.7093C628.209 19.1791 630.362 21.0064 632.952 21.4596L668.686 27.8081C671.26 28.2625 673.865 27.3458 675.558 25.3798L731.485 -39.5858C733.094 -41.4548 733.626 -44.0383 732.897 -46.4193L723.582 -76.5869C723.392 -77.2127 722.995 -78.4467 722.55 -79.8598C721.599 -82.8478 717.745 -83.6543 715.736 -81.285C714.871 -80.28 714.081 -79.3631 713.596 -78.7989L663.702 -21.0578C661.061 -17.9897 656.056 -18.9746 654.68 -22.8213L628.992 -94.9357C628.063 -97.5354 625.837 -99.4571 623.141 -99.9861L548.121 -114.796C544.118 -115.593 542.417 -120.396 545.05 -123.471L594.73 -181.397C595.17 -181.908 595.966 -182.85 596.867 -183.916C598.861 -186.267 597.49 -189.941 594.416 -190.427C593.038 -190.648 591.785 -190.853 591.072 -190.962L559.901 -195.739C557.441 -196.11 554.975 -195.211 553.359 -193.333L497.432 -128.368C495.739 -126.402 495.211 -123.677 496.046 -121.204L496.037 -121.212Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M501.308 204.961L543.017 213.364C546.421 214.043 549.227 210.748 547.989 207.498L532.826 167.412C531.818 164.76 528.35 164.118 526.514 166.251L498.95 198.269C496.909 200.641 498.216 204.351 501.301 204.969L501.308 204.961Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M586.669 278.639L572.221 238.57C571.047 235.299 573.888 232.035 577.282 232.782L619.112 241.889C621.882 242.497 623.026 245.836 621.19 247.969L593.626 279.988C591.585 282.359 587.728 281.612 586.662 278.648L586.669 278.639Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M698.974 236.383L687.453 201.928C686.615 199.438 684.501 197.567 681.931 197.054L601.102 180.949C598.532 180.436 596.412 178.607 595.527 176.137L567.687 98.4619C566.802 95.9921 564.649 94.1648 562.059 93.7116L526.325 87.3631C523.752 86.9087 521.146 87.8254 519.454 89.7914L463.527 154.757C461.918 156.626 461.386 159.209 462.115 161.59L471.429 191.758C471.62 192.384 472.017 193.618 472.461 195.031C473.413 198.019 477.267 198.826 479.276 196.456C480.141 195.451 480.93 194.534 481.416 193.97L531.309 136.229C533.951 133.161 538.955 134.146 540.332 137.993L566.02 210.107C566.948 212.707 569.175 214.628 571.871 215.157L646.89 229.967C650.893 230.764 652.594 235.567 649.962 238.643L600.282 296.568C599.842 297.079 599.046 298.022 598.144 299.087C596.151 301.439 597.522 305.112 600.595 305.598C601.973 305.819 603.226 306.024 603.939 306.133L635.111 310.91C637.57 311.281 640.036 310.382 641.653 308.504L697.58 243.539C699.272 241.573 699.801 238.848 698.965 236.375L698.974 236.383Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M674 55.1032L632.291 46.7001C628.887 46.0206 626.082 49.3155 627.319 52.5657L642.483 92.6519C643.49 95.3041 646.958 95.9462 648.795 93.8128L676.358 61.7945C678.4 59.4231 677.092 55.7129 674.008 55.0944L674 55.1032Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M588.639 -18.5751L603.088 21.4934C604.261 24.7648 601.42 28.0289 598.027 27.2821L556.196 18.1745C553.427 17.5667 552.282 14.2277 554.119 12.0943L581.682 -19.924C583.724 -22.2954 587.58 -21.5481 588.647 -18.584L588.639 -18.5751Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M476.334 23.6808L487.856 58.1354C488.694 60.626 490.808 62.4968 493.378 63.0093L574.206 79.1146C576.776 79.6273 578.896 81.4568 579.781 83.9267L607.621 161.602C608.506 164.072 610.659 165.899 613.249 166.352L648.983 172.701C651.557 173.155 654.162 172.238 655.855 170.272L711.781 105.307C713.39 103.438 713.922 100.854 713.193 98.4733L703.879 68.3057C703.688 67.6799 703.291 66.4459 702.847 65.0328C701.896 62.0448 698.041 61.2382 696.033 63.6075C695.168 64.6126 694.378 65.5295 693.893 66.0937L643.999 123.835C641.358 126.903 636.353 125.918 634.977 122.071L609.289 49.9569C608.36 47.3571 606.134 45.4354 603.438 44.9065L528.418 30.0964C524.415 29.3 522.714 24.4969 525.346 21.4212L575.027 -36.5042C575.467 -37.0156 576.262 -37.9578 577.164 -39.0234C579.158 -41.3749 577.786 -45.048 574.713 -45.5341C573.335 -45.7554 572.082 -45.9606 571.369 -46.069L540.198 -50.8463C537.738 -51.2171 535.272 -50.3185 533.656 -48.4407L477.729 16.5248C476.036 18.4908 475.508 21.2154 476.343 23.6885L476.334 23.6808Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M931.704 -89.7906L889.995 -98.1936C886.591 -98.8731 883.785 -95.5784 885.023 -92.3281L900.186 -52.2419C901.194 -49.5896 904.662 -48.9476 906.498 -51.081L934.062 -83.0993C936.103 -85.4707 934.796 -89.1809 931.711 -89.7994L931.704 -89.7906Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M846.343 -163.469L860.791 -123.4C861.964 -120.129 859.124 -116.865 855.73 -117.612L813.9 -126.719C811.13 -127.327 809.986 -130.666 811.822 -132.799L839.386 -164.818C841.427 -167.189 845.284 -166.442 846.35 -163.478L846.343 -163.469Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M734.038 -121.213L745.559 -86.7584C746.397 -84.2678 748.511 -82.397 751.081 -81.8845L831.91 -65.7792C834.48 -65.2665 836.6 -63.437 837.485 -60.9671L865.325 16.7081C866.21 19.1779 868.362 21.0052 870.953 21.4584L906.687 27.8069C909.26 28.2613 911.866 27.3445 913.558 25.3785L969.485 -39.587C971.094 -41.456 971.626 -44.0395 970.897 -46.4205L961.583 -76.5881C961.392 -77.2139 960.995 -78.4479 960.551 -79.861C959.599 -82.849 955.745 -83.6556 953.736 -81.2863C952.871 -80.2812 952.082 -79.3643 951.596 -78.8001L901.703 -21.059C899.061 -17.991 894.057 -18.9759 892.68 -22.8226L866.992 -94.9369C866.064 -97.5367 863.837 -99.4584 861.141 -99.9873L786.122 -114.797C782.119 -115.594 780.418 -120.397 783.05 -123.473L832.73 -181.398C833.17 -181.909 833.966 -182.852 834.868 -183.917C836.861 -186.269 835.49 -189.942 832.417 -190.428C831.039 -190.649 829.786 -190.854 829.073 -190.963L797.901 -195.74C795.442 -196.111 792.976 -195.212 791.359 -193.335L735.432 -128.369C733.74 -126.403 733.211 -123.678 734.047 -121.205L734.038 -121.213Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M739.307 204.957L781.016 213.36C784.42 214.04 787.225 210.745 785.988 207.495L770.824 167.409C769.817 164.756 766.349 164.114 764.513 166.248L736.949 198.266C734.907 200.638 736.215 204.348 739.299 204.966L739.307 204.957Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M824.668 278.636L810.219 238.567C809.046 235.296 811.887 232.032 815.28 232.779L857.111 241.886C859.88 242.494 861.025 245.833 859.188 247.966L831.625 279.985C829.583 282.356 825.727 281.609 824.66 278.645L824.668 278.636Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M936.973 236.38L925.452 201.925C924.613 199.435 922.499 197.564 919.929 197.051L839.101 180.946C836.531 180.433 834.411 178.604 833.526 176.134L805.686 98.4587C804.801 95.9889 802.648 94.1616 800.058 93.7084L764.324 87.3599C761.75 86.9055 759.145 87.8223 757.452 89.7883L701.526 154.754C699.917 156.623 699.385 159.206 700.114 161.587L709.428 191.755C709.619 192.381 710.016 193.615 710.46 195.028C711.411 198.016 715.266 198.822 717.274 196.453C718.139 195.448 718.929 194.531 719.415 193.967L769.308 136.226C771.949 133.158 776.954 134.143 778.331 137.989L804.018 210.104C804.947 212.703 807.174 214.625 809.87 215.154L884.889 229.964C888.892 230.761 890.593 235.564 887.961 238.639L838.281 296.565C837.84 297.076 837.045 298.018 836.143 299.084C834.15 301.436 835.521 305.109 838.594 305.595C839.972 305.816 841.225 306.021 841.938 306.13L873.109 310.907C875.569 311.278 878.035 310.379 879.652 308.501L935.578 243.536C937.271 241.57 937.8 238.845 936.964 236.372L936.973 236.38Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M911.999 55.102L870.29 46.6989C866.886 46.0194 864.08 49.3142 865.318 52.5645L880.481 92.6507C881.489 95.3029 884.957 95.9449 886.793 93.8116L914.357 61.7933C916.398 59.4218 915.091 55.7117 912.006 55.0932L911.999 55.102Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M826.638 -18.5764L841.086 21.4922C842.259 24.7636 839.418 28.0277 836.025 27.2808L794.195 18.1733C791.425 17.5655 790.281 14.2265 792.117 12.0931L819.681 -19.9252C821.722 -22.2966 825.579 -21.5494 826.645 -18.5852L826.638 -18.5764Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M714.333 23.6796L725.854 58.1342C726.692 60.6248 728.806 62.4956 731.376 63.0081L812.205 79.1134C814.775 79.6261 816.895 81.4556 817.78 83.9254L845.62 161.601C846.505 164.07 848.657 165.898 851.248 166.351L886.981 172.699C889.555 173.154 892.161 172.237 893.853 170.271L949.78 105.306C951.389 103.437 951.921 100.853 951.192 98.4721L941.878 68.3045C941.687 67.6787 941.29 66.4447 940.846 65.0316C939.894 62.0436 936.04 61.237 934.031 63.6063C933.166 64.6113 932.377 65.5283 931.891 66.0925L881.997 123.834C879.356 126.902 874.352 125.917 872.975 122.07L847.287 49.9557C846.359 47.3559 844.132 45.4342 841.436 44.9053L766.417 30.0952C762.414 29.2988 760.713 24.4957 763.345 21.42L813.025 -36.5054C813.465 -37.0168 814.261 -37.959 815.163 -39.0246C817.156 -41.3761 815.785 -45.0493 812.712 -45.5353C811.333 -45.7567 810.081 -45.9618 809.368 -46.0702L778.196 -50.8475C775.737 -51.2183 773.271 -50.3197 771.654 -48.4419L715.727 16.5236C714.035 18.4896 713.506 21.2142 714.342 23.6873L714.333 23.6796Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1164.7 -89.7906L1122.99 -98.1936C1119.59 -98.8731 1116.78 -95.5784 1118.02 -92.3281L1133.19 -52.2419C1134.19 -49.5896 1137.66 -48.9476 1139.5 -51.081L1167.06 -83.0993C1169.1 -85.4707 1167.79 -89.1809 1164.71 -89.7994L1164.7 -89.7906Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1079.34 -163.469L1093.79 -123.4C1094.96 -120.129 1092.12 -116.865 1088.73 -117.612L1046.9 -126.719C1044.13 -127.327 1042.98 -130.666 1044.82 -132.799L1072.39 -164.818C1074.43 -167.189 1078.28 -166.442 1079.35 -163.478L1079.34 -163.469Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M967.037 -121.213L978.558 -86.7584C979.397 -84.2678 981.511 -82.397 984.081 -81.8845L1064.91 -65.7792C1067.48 -65.2665 1069.6 -63.437 1070.48 -60.9671L1098.32 16.7081C1099.21 19.1779 1101.36 21.0052 1103.95 21.4584L1139.69 27.8069C1142.26 28.2613 1144.87 27.3445 1146.56 25.3785L1202.48 -39.587C1204.09 -41.456 1204.63 -44.0395 1203.9 -46.4205L1194.58 -76.5881C1194.39 -77.2139 1193.99 -78.4479 1193.55 -79.861C1192.6 -82.849 1188.74 -83.6556 1186.74 -81.2863C1185.87 -80.2812 1185.08 -79.3643 1184.6 -78.8001L1134.7 -21.059C1132.06 -17.991 1127.06 -18.9759 1125.68 -22.8226L1099.99 -94.9369C1099.06 -97.5367 1096.84 -99.4584 1094.14 -99.9873L1019.12 -114.797C1015.12 -115.594 1013.42 -120.397 1016.05 -123.473L1065.73 -181.398C1066.17 -181.909 1066.97 -182.852 1067.87 -183.917C1069.86 -186.269 1068.49 -189.942 1065.42 -190.428C1064.04 -190.649 1062.79 -190.854 1062.07 -190.963L1030.9 -195.74C1028.44 -196.111 1025.97 -195.212 1024.36 -193.335L968.432 -128.369C966.739 -126.403 966.21 -123.678 967.046 -121.205L967.037 -121.213Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M972.308 204.959L1014.02 213.362C1017.42 214.042 1020.23 210.747 1018.99 207.497L1003.83 167.411C1002.82 164.758 999.35 164.116 997.514 166.25L969.95 198.268C967.908 200.639 969.216 204.35 972.3 204.968L972.308 204.959Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1057.67 278.638L1043.22 238.569C1042.05 235.298 1044.89 232.034 1048.28 232.78L1090.11 241.888C1092.88 242.496 1094.03 245.835 1092.19 247.968L1064.63 279.987C1062.58 282.358 1058.73 281.611 1057.66 278.647L1057.67 278.638Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1169.97 236.382L1158.45 201.927C1157.61 199.437 1155.5 197.566 1152.93 197.053L1072.1 180.948C1069.53 180.435 1067.41 178.606 1066.53 176.136L1038.69 98.4607C1037.8 95.9908 1035.65 94.1636 1033.06 93.7104L997.325 87.3618C994.751 86.9075 992.146 87.8242 990.453 89.7902L934.527 154.756C932.918 156.625 932.386 159.208 933.115 161.589L942.429 191.757C942.62 192.383 943.017 193.617 943.461 195.03C944.413 198.018 948.267 198.824 950.275 196.455C951.14 195.45 951.93 194.533 952.416 193.969L1002.31 136.228C1004.95 133.16 1009.95 134.145 1011.33 137.991L1037.02 210.106C1037.95 212.705 1040.17 214.627 1042.87 215.156L1117.89 229.966C1121.89 230.763 1123.59 235.566 1120.96 238.641L1071.28 296.567C1070.84 297.078 1070.05 298.02 1069.14 299.086C1067.15 301.437 1068.52 305.111 1071.6 305.597C1072.97 305.818 1074.23 306.023 1074.94 306.132L1106.11 310.909C1108.57 311.28 1111.04 310.381 1112.65 308.503L1168.58 243.538C1170.27 241.572 1170.8 238.847 1169.96 236.374L1169.97 236.382Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1145 55.102L1103.29 46.6989C1099.89 46.0194 1097.08 49.3142 1098.32 52.5645L1113.48 92.6507C1114.49 95.3029 1117.96 95.9449 1119.79 93.8116L1147.36 61.7933C1149.4 59.4218 1148.09 55.7117 1145.01 55.0932L1145 55.102Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M1059.64 -18.5764L1074.09 21.4922C1075.26 24.7636 1072.42 28.0277 1069.03 27.2808L1027.2 18.1733C1024.43 17.5655 1023.28 14.2265 1025.12 12.0931L1052.68 -19.9252C1054.72 -22.2966 1058.58 -21.5494 1059.65 -18.5852L1059.64 -18.5764Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M947.334 23.6796L958.855 58.1342C959.694 60.6248 961.808 62.4956 964.377 63.0081L1045.21 79.1134C1047.78 79.6261 1049.9 81.4556 1050.78 83.9254L1078.62 161.601C1079.51 164.07 1081.66 165.898 1084.25 166.351L1119.98 172.699C1122.56 173.154 1125.16 172.237 1126.85 170.271L1182.78 105.306C1184.39 103.437 1184.92 100.853 1184.19 98.4721L1174.88 68.3045C1174.69 67.6787 1174.29 66.4447 1173.85 65.0316C1172.9 62.0436 1169.04 61.237 1167.03 63.6063C1166.17 64.6113 1165.38 65.5283 1164.89 66.0925L1115 123.834C1112.36 126.902 1107.35 125.917 1105.98 122.07L1080.29 49.9557C1079.36 47.3559 1077.13 45.4342 1074.44 44.9053L999.418 30.0952C995.415 29.2988 993.714 24.4957 996.346 21.42L1046.03 -36.5054C1046.47 -37.0168 1047.26 -37.959 1048.16 -39.0246C1050.16 -41.3761 1048.79 -45.0493 1045.71 -45.5353C1044.33 -45.7567 1043.08 -45.9618 1042.37 -46.0702L1011.2 -50.8475C1008.74 -51.2183 1006.27 -50.3197 1004.66 -48.4419L948.728 16.5236C947.036 18.4896 946.507 21.2142 947.343 23.6873L947.334 23.6796Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-10.2954 -89.7842L-52.0041 -98.1873C-55.4084 -98.8668 -58.2138 -95.572 -56.976 -92.3217L-41.8127 -52.2355C-40.805 -49.5833 -37.3374 -48.9413 -35.5009 -51.0746L-7.93733 -83.093C-5.89584 -85.4644 -7.20342 -89.1745 -10.2878 -89.7931L-10.2954 -89.7842Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-95.6563 -163.463L-81.2076 -123.394C-80.0346 -120.123 -82.8755 -116.859 -86.2686 -117.605L-128.099 -126.713C-130.869 -127.321 -132.013 -130.66 -130.177 -132.793L-102.613 -164.811C-100.572 -167.183 -96.7151 -166.436 -95.6487 -163.471L-95.6563 -163.463Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-207.961 -121.207L-196.44 -86.7521C-195.602 -84.2614 -193.488 -82.3906 -190.918 -81.8782L-110.089 -65.7728C-107.519 -65.2602 -105.399 -63.4306 -104.514 -60.9608L-76.6744 16.7144C-75.7891 19.1843 -73.6365 21.0115 -71.0462 21.4648L-35.3125 27.8132C-32.7387 28.2676 -30.1333 27.3509 -28.4408 25.3849L27.486 -39.5807C29.0949 -41.4496 29.627 -44.0332 28.898 -46.4142L19.5838 -76.5818C19.3929 -77.2076 18.996 -78.4415 18.5516 -79.8546C17.6002 -82.8427 13.746 -83.6492 11.7374 -81.2799C10.8722 -80.2749 10.0828 -79.358 9.59708 -78.7937L-40.2965 -21.0526C-42.9377 -17.9846 -47.9423 -18.9695 -49.3189 -22.8162L-75.0067 -94.9306C-75.9355 -97.5303 -78.1619 -99.452 -80.8578 -99.9809L-155.877 -114.791C-159.88 -115.587 -161.581 -120.391 -158.949 -123.466L-109.269 -181.392C-108.829 -181.903 -108.033 -182.845 -107.131 -183.911C-105.138 -186.262 -106.509 -189.935 -109.582 -190.422C-110.961 -190.643 -112.213 -190.848 -112.926 -190.956L-144.098 -195.734C-146.557 -196.105 -149.023 -195.206 -150.64 -193.328L-206.567 -128.363C-208.259 -126.397 -208.788 -123.672 -207.952 -121.199L-207.961 -121.207Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-202.692 204.964L-160.984 213.367C-157.579 214.046 -154.774 210.752 -156.012 207.501L-171.175 167.415C-172.183 164.763 -175.65 164.121 -177.487 166.254L-205.05 198.272C-207.092 200.644 -205.784 204.354 -202.7 204.973L-202.692 204.964Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-117.331 278.642L-131.78 238.574C-132.953 235.302 -130.112 232.038 -126.719 232.785L-84.8883 241.892C-82.119 242.5 -80.9743 245.839 -82.8109 247.973L-110.374 279.991C-112.416 282.362 -116.273 281.615 -117.339 278.651L-117.331 278.642Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-5.0264 236.386L-16.5478 201.932C-17.386 199.441 -19.5 197.57 -22.0699 197.058L-102.898 180.952C-105.469 180.44 -107.588 178.61 -108.474 176.14L-136.313 98.4651C-137.198 95.9952 -139.351 94.168 -141.941 93.7148L-177.675 87.3662C-180.249 86.9119 -182.854 87.8286 -184.547 89.7946L-240.474 154.76C-242.083 156.629 -242.615 159.213 -241.886 161.594L-232.571 191.761C-232.381 192.387 -231.984 193.621 -231.539 195.034C-230.588 198.022 -226.734 198.829 -224.725 196.459C-223.86 195.454 -223.07 194.537 -222.585 193.973L-172.691 136.232C-170.05 133.164 -165.045 134.149 -163.669 137.996L-137.981 210.11C-137.052 212.71 -134.826 214.631 -132.13 215.16L-57.1103 229.971C-53.1076 230.767 -51.4064 235.57 -54.0387 238.646L-103.719 296.571C-104.159 297.083 -104.955 298.025 -105.856 299.09C-107.85 301.442 -106.479 305.115 -103.405 305.601C-102.027 305.822 -100.775 306.028 -100.061 306.136L-68.8898 310.913C-66.4306 311.284 -63.9642 310.385 -62.3477 308.508L-6.42088 243.542C-4.72842 241.576 -4.19972 238.852 -5.03531 236.378L-5.0264 236.386Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-30.0004 55.1083L-71.7092 46.7053C-75.1135 46.0258 -77.9188 49.3206 -76.6811 52.5708L-61.5178 92.657C-60.5101 95.3093 -57.0425 95.9513 -55.206 93.8179L-27.6424 61.7996C-25.6009 59.4282 -26.9085 55.7181 -29.9929 55.0995L-30.0004 55.1083Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-115.361 -18.57L-100.913 21.4985C-99.7397 24.7699 -102.581 28.034 -105.974 27.2872L-147.804 18.1796C-150.574 17.5719 -151.718 14.2328 -149.882 12.0995L-122.318 -19.9189C-120.277 -22.2903 -116.42 -21.543 -115.354 -18.5788L-115.361 -18.57Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M-227.666 23.6859L-216.145 58.1405C-215.307 60.6312 -213.193 62.5019 -210.623 63.0144L-129.794 79.1198C-127.224 79.6324 -125.104 81.4619 -124.219 83.9318L-96.3795 161.607C-95.4942 164.077 -93.3416 165.904 -90.7513 166.357L-55.0176 172.706C-52.4438 173.16 -49.8384 172.243 -48.1459 170.277L7.78091 105.312C9.38986 103.443 9.9219 100.859 9.19295 98.4784L-0.121262 68.3108C-0.312151 67.685 -0.709083 66.451 -1.15346 65.038C-2.10493 62.0499 -5.95909 61.2434 -7.96773 63.6127C-8.83292 64.6177 -9.62228 65.5346 -10.108 66.0988L-60.0016 123.84C-62.6428 126.908 -67.6474 125.923 -69.0239 122.076L-94.7117 49.962C-95.6405 47.3623 -97.8669 45.4406 -100.563 44.9116L-175.582 30.1016C-179.585 29.3052 -181.286 24.5021 -178.654 21.4264L-128.974 -36.4991C-128.534 -37.0104 -127.738 -37.9527 -126.836 -39.0182C-124.843 -41.3698 -126.214 -45.0429 -129.288 -45.5289C-130.666 -45.7503 -131.918 -45.9554 -132.631 -46.0639L-163.803 -50.8412C-166.262 -51.212 -168.728 -50.3134 -170.345 -48.4356L-226.272 16.5299C-227.964 18.4959 -228.493 21.2206 -227.657 23.6936L-227.666 23.6859Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M222.706 -89.7842L180.997 -98.1873C177.593 -98.8668 174.787 -95.572 176.025 -92.3217L191.188 -52.2355C192.196 -49.5833 195.663 -48.9413 197.5 -51.0746L225.064 -83.093C227.105 -85.4644 225.798 -89.1745 222.713 -89.7931L222.706 -89.7842Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M137.345 -163.463L151.793 -123.394C152.966 -120.123 150.125 -116.859 146.732 -117.605L104.902 -126.713C102.132 -127.321 100.988 -130.66 102.824 -132.793L130.388 -164.811C132.429 -167.183 136.286 -166.436 137.352 -163.471L137.345 -163.463Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M25.0397 -121.207L36.5611 -86.7521C37.3993 -84.2614 39.5133 -82.3906 42.0832 -81.8782L122.912 -65.7728C125.482 -65.2602 127.602 -63.4306 128.487 -60.9608L156.327 16.7144C157.212 19.1843 159.364 21.0115 161.955 21.4648L197.688 27.8132C200.262 28.2676 202.868 27.3509 204.56 25.3849L260.487 -39.5807C262.096 -41.4496 262.628 -44.0332 261.899 -46.4142L252.585 -76.5818C252.394 -77.2076 251.997 -78.4415 251.553 -79.8546C250.601 -82.8427 246.747 -83.6492 244.738 -81.2799C243.873 -80.2749 243.084 -79.358 242.598 -78.7937L192.704 -21.0526C190.063 -17.9846 185.059 -18.9695 183.682 -22.8162L157.994 -94.9306C157.066 -97.5303 154.839 -99.452 152.143 -99.9809L77.1236 -114.791C73.1209 -115.587 71.4197 -120.391 74.052 -123.466L123.732 -181.392C124.172 -181.903 124.968 -182.845 125.87 -183.911C127.863 -186.262 126.492 -189.935 123.418 -190.422C122.04 -190.643 120.788 -190.848 120.075 -190.956L88.9031 -195.734C86.4439 -196.105 83.9776 -195.206 82.361 -193.328L26.4342 -128.363C24.7417 -126.397 24.213 -123.672 25.0486 -121.199L25.0397 -121.207Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M30.3106 204.966L72.0193 213.369C75.4237 214.048 78.229 210.753 76.9913 207.503L61.828 167.417C60.8203 164.765 57.3527 164.123 55.5162 166.256L27.9526 198.274C25.9111 200.646 27.2187 204.356 30.303 204.975L30.3106 204.966Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M115.672 278.644L101.223 238.576C100.05 235.304 102.891 232.04 106.284 232.787L148.115 241.894C150.884 242.502 152.029 245.841 150.192 247.975L122.628 279.993C120.587 282.364 116.73 281.617 115.664 278.653L115.672 278.644Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M227.977 236.388L216.455 201.934C215.617 199.443 213.503 197.572 210.933 197.06L130.104 180.954C127.534 180.442 125.415 178.612 124.529 176.142L96.6897 98.467C95.8044 95.9972 93.6518 94.17 91.0615 93.7167L55.3278 87.3682C52.754 86.9138 50.1486 87.8306 48.4561 89.7966L-7.47073 154.762C-9.07968 156.631 -9.61172 159.215 -8.88277 161.596L0.431442 191.763C0.622332 192.389 1.01926 193.623 1.46364 195.036C2.41511 198.024 6.26927 198.831 8.27791 196.461C9.1431 195.456 9.93247 194.539 10.4182 193.975L60.3118 136.234C62.953 133.166 67.9575 134.151 69.3341 137.998L95.0219 210.112C95.9507 212.712 98.1771 214.633 100.873 215.162L175.893 229.972C179.895 230.769 181.597 235.572 178.964 238.648L129.284 296.573C128.844 297.084 128.048 298.027 127.147 299.092C125.153 301.444 126.524 305.117 129.598 305.603C130.976 305.824 132.228 306.029 132.942 306.138L164.113 310.915C166.572 311.286 169.039 310.387 170.655 308.51L226.582 243.544C228.274 241.578 228.803 238.853 227.968 236.38L227.977 236.388Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M203.002 55.1083L161.294 46.7053C157.889 46.0258 155.084 49.3206 156.322 52.5708L171.485 92.657C172.493 95.3093 175.96 95.9513 177.797 93.8179L205.361 61.7996C207.402 59.4282 206.094 55.7181 203.01 55.0995L203.002 55.1083Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M117.642 -18.57L132.09 21.4985C133.263 24.7699 130.422 28.034 127.029 27.2872L85.1985 18.1796C82.4292 17.5719 81.2845 14.2328 83.121 12.0995L110.685 -19.9189C112.726 -22.2903 116.583 -21.543 117.649 -18.5788L117.642 -18.57Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M5.33659 23.6859L16.858 58.1405C17.6961 60.6312 19.8102 62.5019 22.3801 63.0144L103.209 79.1198C105.779 79.6324 107.898 81.4619 108.784 83.9318L136.623 161.607C137.509 164.077 139.661 165.904 142.252 166.357L177.985 172.706C180.559 173.16 183.165 172.243 184.857 170.277L240.784 105.312C242.393 103.443 242.925 100.859 242.196 98.4784L232.882 68.3108C232.691 67.685 232.294 66.451 231.849 65.038C230.898 62.0499 227.044 61.2434 225.035 63.6127C224.17 64.6177 223.381 65.5346 222.895 66.0988L173.001 123.84C170.36 126.908 165.356 125.923 163.979 122.076L138.291 49.962C137.362 47.3623 135.136 45.4406 132.44 44.9116L57.4205 30.1016C53.4178 29.3052 51.7166 24.5021 54.3488 21.4264L104.029 -36.4991C104.469 -37.0104 105.265 -37.9527 106.167 -39.0182C108.16 -41.3698 106.789 -45.0429 103.715 -45.5289C102.337 -45.7503 101.085 -45.9554 100.372 -46.0639L69.2 -50.8412C66.7408 -51.212 64.2744 -50.3134 62.6579 -48.4356L6.73106 16.5299C5.0386 18.4959 4.5099 21.2206 5.34549 23.6936L5.33659 23.6859Z"
fill="currentColor" fill-opacity="0.08" />
<path
d="M460.704 -89.7906L418.995 -98.1936C415.591 -98.8731 412.786 -95.5784 414.023 -92.3281L429.187 -52.2419C430.194 -49.5896 433.662 -48.9476 435.498 -51.081L463.062 -83.0993C465.103 -85.4707 463.796 -89.1809 460.711 -89.7994L460.704 -89.7906Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M375.343 -163.469L389.792 -123.4C390.965 -120.129 388.124 -116.865 384.731 -117.612L342.9 -126.719C340.131 -127.327 338.986 -130.666 340.823 -132.799L368.386 -164.818C370.428 -167.189 374.284 -166.442 375.351 -163.478L375.343 -163.469Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M263.038 -121.213L274.559 -86.7584C275.398 -84.2678 277.512 -82.397 280.082 -81.8845L360.91 -65.7792C363.48 -65.2665 365.6 -63.437 366.485 -60.9671L394.325 16.7081C395.21 19.1779 397.363 21.0052 399.953 21.4584L435.687 27.8069C438.261 28.2613 440.866 27.3445 442.558 25.3785L498.485 -39.587C500.094 -41.456 500.626 -44.0395 499.897 -46.4205L490.583 -76.5881C490.392 -77.2139 489.995 -78.4479 489.551 -79.861C488.599 -82.849 484.745 -83.6556 482.737 -81.2863C481.871 -80.2812 481.082 -79.3643 480.596 -78.8001L430.703 -21.059C428.062 -17.991 423.057 -18.9759 421.68 -22.8226L395.993 -94.9369C395.064 -97.5367 392.837 -99.4584 390.141 -99.9873L315.122 -114.797C311.119 -115.594 309.418 -120.397 312.05 -123.473L361.73 -181.398C362.171 -181.909 362.966 -182.852 363.868 -183.917C365.861 -186.269 364.49 -189.942 361.417 -190.428C360.039 -190.649 358.786 -190.854 358.073 -190.963L326.901 -195.74C324.442 -196.111 321.976 -195.212 320.359 -193.335L264.433 -128.369C262.74 -126.403 262.211 -123.678 263.047 -121.205L263.038 -121.213Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M268.307 204.958L310.016 213.361C313.42 214.041 316.226 210.746 314.988 207.496L299.825 167.41C298.817 164.757 295.349 164.115 293.513 166.249L265.949 198.267C263.908 200.639 265.215 204.349 268.3 204.967L268.307 204.958Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M353.668 278.637L339.219 238.568C338.046 235.297 340.887 232.033 344.28 232.78L386.111 241.887C388.88 242.495 390.025 245.834 388.189 247.967L360.625 279.986C358.584 282.357 354.727 281.61 353.66 278.646L353.668 278.637Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M465.973 236.381L454.452 201.926C453.613 199.436 451.499 197.565 448.93 197.052L368.101 180.947C365.531 180.434 363.411 178.605 362.526 176.135L334.686 98.4597C333.801 95.9899 331.648 94.1626 329.058 93.7094L293.324 87.3609C290.75 86.9065 288.145 87.8232 286.453 89.7892L230.526 154.755C228.917 156.624 228.385 159.207 229.114 161.588L238.428 191.756C238.619 192.382 239.016 193.616 239.46 195.029C240.412 198.017 244.266 198.823 246.274 196.454C247.14 195.449 247.929 194.532 248.415 193.968L298.308 136.227C300.95 133.159 305.954 134.144 307.331 137.99L333.018 210.105C333.947 212.704 336.174 214.626 338.87 215.155L413.889 229.965C417.892 230.762 419.593 235.565 416.961 238.64L367.281 296.566C366.84 297.077 366.045 298.019 365.143 299.085C363.15 301.436 364.521 305.11 367.594 305.596C368.972 305.817 370.225 306.022 370.938 306.131L402.11 310.908C404.569 311.279 407.035 310.38 408.652 308.502L464.579 243.537C466.271 241.571 466.8 238.846 465.964 236.373L465.973 236.381Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M440.999 55.1022L399.29 46.6992C395.886 46.0197 393.08 49.3145 394.318 52.5647L409.482 92.6509C410.489 95.3032 413.957 95.9452 415.793 93.8118L443.357 61.7935C445.398 59.4221 444.091 55.7119 441.006 55.0934L440.999 55.1022Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M355.638 -18.5761L370.087 21.4924C371.26 24.7638 368.419 28.0279 365.026 27.2811L323.195 18.1735C320.426 17.5658 319.281 14.2267 321.117 12.0934L348.681 -19.925C350.723 -22.2964 354.579 -21.5491 355.646 -18.5849L355.638 -18.5761Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M243.333 23.6798L254.854 58.1344C255.693 60.6251 257.807 62.4958 260.376 63.0083L341.205 79.1136C343.775 79.6263 345.895 81.4558 346.78 83.9257L374.62 161.601C375.505 164.071 377.658 165.898 380.248 166.351L415.982 172.7C418.555 173.154 421.161 172.237 422.853 170.271L478.78 105.306C480.389 103.437 480.921 100.853 480.192 98.4723L470.878 68.3047C470.687 67.6789 470.29 66.4449 469.846 65.0319C468.894 62.0438 465.04 61.2373 463.032 63.6066C462.166 64.6116 461.377 65.5285 460.891 66.0927L410.998 123.834C408.357 126.902 403.352 125.917 401.975 122.07L376.288 49.9559C375.359 47.3562 373.132 45.4345 370.436 44.9055L295.417 30.0955C291.414 29.2991 289.713 24.496 292.345 21.4203L342.025 -36.5052C342.466 -37.0165 343.261 -37.9588 344.163 -39.0243C346.156 -41.3759 344.785 -45.049 341.712 -45.535C340.334 -45.7564 339.081 -45.9615 338.368 -46.07L307.196 -50.8473C304.737 -51.2181 302.271 -50.3195 300.654 -48.4417L244.727 16.5238C243.035 18.4898 242.506 21.2145 243.342 23.6875L243.333 23.6798Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M693.703 -89.7894L651.995 -98.1924C648.59 -98.8719 645.785 -95.5771 647.023 -92.3269L662.186 -52.2407C663.194 -49.5884 666.661 -48.9464 668.498 -51.0798L696.061 -83.0981C698.103 -85.4695 696.795 -89.1797 693.711 -89.7982L693.703 -89.7894Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M608.342 -163.468L622.791 -123.399C623.964 -120.128 621.123 -116.864 617.73 -117.611L575.899 -126.718C573.13 -127.326 571.985 -130.665 573.822 -132.798L601.385 -164.817C603.427 -167.188 607.283 -166.441 608.35 -163.477L608.342 -163.468Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M496.037 -121.212L507.559 -86.7572C508.397 -84.2665 510.511 -82.3958 513.081 -81.8833L593.909 -65.778C596.48 -65.2653 598.599 -63.4358 599.484 -60.9659L627.324 16.7093C628.209 19.1791 630.362 21.0064 632.952 21.4596L668.686 27.8081C671.26 28.2625 673.865 27.3458 675.558 25.3798L731.485 -39.5858C733.094 -41.4548 733.626 -44.0383 732.897 -46.4193L723.582 -76.5869C723.392 -77.2127 722.995 -78.4467 722.55 -79.8598C721.599 -82.8478 717.745 -83.6543 715.736 -81.285C714.871 -80.28 714.081 -79.3631 713.596 -78.7989L663.702 -21.0578C661.061 -17.9897 656.056 -18.9746 654.68 -22.8213L628.992 -94.9357C628.063 -97.5354 625.837 -99.4571 623.141 -99.9861L548.121 -114.796C544.118 -115.593 542.417 -120.396 545.05 -123.471L594.73 -181.397C595.17 -181.908 595.966 -182.85 596.867 -183.916C598.861 -186.267 597.49 -189.941 594.416 -190.427C593.038 -190.648 591.785 -190.853 591.072 -190.962L559.901 -195.739C557.441 -196.11 554.975 -195.211 553.359 -193.333L497.432 -128.368C495.739 -126.402 495.211 -123.677 496.046 -121.204L496.037 -121.212Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M501.308 204.961L543.017 213.364C546.421 214.043 549.227 210.748 547.989 207.498L532.826 167.412C531.818 164.76 528.35 164.118 526.514 166.251L498.95 198.269C496.909 200.641 498.216 204.351 501.301 204.969L501.308 204.961Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M586.669 278.639L572.221 238.57C571.047 235.299 573.888 232.035 577.282 232.782L619.112 241.889C621.882 242.497 623.026 245.836 621.19 247.969L593.626 279.988C591.585 282.359 587.728 281.612 586.662 278.648L586.669 278.639Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M698.974 236.383L687.453 201.928C686.615 199.438 684.501 197.567 681.931 197.054L601.102 180.949C598.532 180.436 596.412 178.607 595.527 176.137L567.687 98.4619C566.802 95.9921 564.649 94.1648 562.059 93.7116L526.325 87.3631C523.752 86.9087 521.146 87.8254 519.454 89.7914L463.527 154.757C461.918 156.626 461.386 159.209 462.115 161.59L471.429 191.758C471.62 192.384 472.017 193.618 472.461 195.031C473.413 198.019 477.267 198.826 479.276 196.456C480.141 195.451 480.93 194.534 481.416 193.97L531.309 136.229C533.951 133.161 538.955 134.146 540.332 137.993L566.02 210.107C566.948 212.707 569.175 214.628 571.871 215.157L646.89 229.967C650.893 230.764 652.594 235.567 649.962 238.643L600.282 296.568C599.842 297.079 599.046 298.022 598.144 299.087C596.151 301.439 597.522 305.112 600.595 305.598C601.973 305.819 603.226 306.024 603.939 306.133L635.111 310.91C637.57 311.281 640.036 310.382 641.653 308.504L697.58 243.539C699.272 241.573 699.801 238.848 698.965 236.375L698.974 236.383Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M674 55.1032L632.291 46.7001C628.887 46.0206 626.082 49.3155 627.319 52.5657L642.483 92.6519C643.49 95.3041 646.958 95.9462 648.795 93.8128L676.358 61.7945C678.4 59.4231 677.092 55.7129 674.008 55.0944L674 55.1032Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M588.639 -18.5751L603.088 21.4934C604.261 24.7648 601.42 28.0289 598.027 27.2821L556.196 18.1745C553.427 17.5667 552.282 14.2277 554.119 12.0943L581.682 -19.924C583.724 -22.2954 587.58 -21.5481 588.647 -18.584L588.639 -18.5751Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M476.334 23.6808L487.856 58.1354C488.694 60.626 490.808 62.4968 493.378 63.0093L574.206 79.1146C576.776 79.6273 578.896 81.4568 579.781 83.9267L607.621 161.602C608.506 164.072 610.659 165.899 613.249 166.352L648.983 172.701C651.557 173.155 654.162 172.238 655.855 170.272L711.781 105.307C713.39 103.438 713.922 100.854 713.193 98.4733L703.879 68.3057C703.688 67.6799 703.291 66.4459 702.847 65.0328C701.896 62.0448 698.041 61.2382 696.033 63.6075C695.168 64.6126 694.378 65.5295 693.893 66.0937L643.999 123.835C641.358 126.903 636.353 125.918 634.977 122.071L609.289 49.9569C608.36 47.3571 606.134 45.4354 603.438 44.9065L528.418 30.0964C524.415 29.3 522.714 24.4969 525.346 21.4212L575.027 -36.5042C575.467 -37.0156 576.262 -37.9578 577.164 -39.0234C579.158 -41.3749 577.786 -45.048 574.713 -45.5341C573.335 -45.7554 572.082 -45.9606 571.369 -46.069L540.198 -50.8463C537.738 -51.2171 535.272 -50.3185 533.656 -48.4407L477.729 16.5248C476.036 18.4908 475.508 21.2154 476.343 23.6885L476.334 23.6808Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M931.704 -89.7906L889.995 -98.1936C886.591 -98.8731 883.785 -95.5784 885.023 -92.3281L900.186 -52.2419C901.194 -49.5896 904.662 -48.9476 906.498 -51.081L934.062 -83.0993C936.103 -85.4707 934.796 -89.1809 931.711 -89.7994L931.704 -89.7906Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M846.343 -163.469L860.791 -123.4C861.964 -120.129 859.124 -116.865 855.73 -117.612L813.9 -126.719C811.13 -127.327 809.986 -130.666 811.822 -132.799L839.386 -164.818C841.427 -167.189 845.284 -166.442 846.35 -163.478L846.343 -163.469Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M734.038 -121.213L745.559 -86.7584C746.397 -84.2678 748.511 -82.397 751.081 -81.8845L831.91 -65.7792C834.48 -65.2665 836.6 -63.437 837.485 -60.9671L865.325 16.7081C866.21 19.1779 868.362 21.0052 870.953 21.4584L906.687 27.8069C909.26 28.2613 911.866 27.3445 913.558 25.3785L969.485 -39.587C971.094 -41.456 971.626 -44.0395 970.897 -46.4205L961.583 -76.5881C961.392 -77.2139 960.995 -78.4479 960.551 -79.861C959.599 -82.849 955.745 -83.6556 953.736 -81.2863C952.871 -80.2812 952.082 -79.3643 951.596 -78.8001L901.703 -21.059C899.061 -17.991 894.057 -18.9759 892.68 -22.8226L866.992 -94.9369C866.064 -97.5367 863.837 -99.4584 861.141 -99.9873L786.122 -114.797C782.119 -115.594 780.418 -120.397 783.05 -123.473L832.73 -181.398C833.17 -181.909 833.966 -182.852 834.868 -183.917C836.861 -186.269 835.49 -189.942 832.417 -190.428C831.039 -190.649 829.786 -190.854 829.073 -190.963L797.901 -195.74C795.442 -196.111 792.976 -195.212 791.359 -193.335L735.432 -128.369C733.74 -126.403 733.211 -123.678 734.047 -121.205L734.038 -121.213Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M739.307 204.957L781.016 213.36C784.42 214.04 787.225 210.745 785.988 207.495L770.824 167.409C769.817 164.756 766.349 164.114 764.513 166.248L736.949 198.266C734.907 200.638 736.215 204.348 739.299 204.966L739.307 204.957Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M824.668 278.636L810.219 238.567C809.046 235.296 811.887 232.032 815.28 232.779L857.111 241.886C859.88 242.494 861.025 245.833 859.188 247.966L831.625 279.985C829.583 282.356 825.727 281.609 824.66 278.645L824.668 278.636Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M936.973 236.38L925.452 201.925C924.613 199.435 922.499 197.564 919.929 197.051L839.101 180.946C836.531 180.433 834.411 178.604 833.526 176.134L805.686 98.4587C804.801 95.9889 802.648 94.1616 800.058 93.7084L764.324 87.3599C761.75 86.9055 759.145 87.8223 757.452 89.7883L701.526 154.754C699.917 156.623 699.385 159.206 700.114 161.587L709.428 191.755C709.619 192.381 710.016 193.615 710.46 195.028C711.411 198.016 715.266 198.822 717.274 196.453C718.139 195.448 718.929 194.531 719.415 193.967L769.308 136.226C771.949 133.158 776.954 134.143 778.331 137.989L804.018 210.104C804.947 212.703 807.174 214.625 809.87 215.154L884.889 229.964C888.892 230.761 890.593 235.564 887.961 238.639L838.281 296.565C837.84 297.076 837.045 298.018 836.143 299.084C834.15 301.436 835.521 305.109 838.594 305.595C839.972 305.816 841.225 306.021 841.938 306.13L873.109 310.907C875.569 311.278 878.035 310.379 879.652 308.501L935.578 243.536C937.271 241.57 937.8 238.845 936.964 236.372L936.973 236.38Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M911.999 55.102L870.29 46.6989C866.886 46.0194 864.08 49.3142 865.318 52.5645L880.481 92.6507C881.489 95.3029 884.957 95.9449 886.793 93.8116L914.357 61.7933C916.398 59.4218 915.091 55.7117 912.006 55.0932L911.999 55.102Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M826.638 -18.5764L841.086 21.4922C842.259 24.7636 839.418 28.0277 836.025 27.2808L794.195 18.1733C791.425 17.5655 790.281 14.2265 792.117 12.0931L819.681 -19.9252C821.722 -22.2966 825.579 -21.5494 826.645 -18.5852L826.638 -18.5764Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M714.333 23.6796L725.854 58.1342C726.692 60.6248 728.806 62.4956 731.376 63.0081L812.205 79.1134C814.775 79.6261 816.895 81.4556 817.78 83.9254L845.62 161.601C846.505 164.07 848.657 165.898 851.248 166.351L886.981 172.699C889.555 173.154 892.161 172.237 893.853 170.271L949.78 105.306C951.389 103.437 951.921 100.853 951.192 98.4721L941.878 68.3045C941.687 67.6787 941.29 66.4447 940.846 65.0316C939.894 62.0436 936.04 61.237 934.031 63.6063C933.166 64.6113 932.377 65.5283 931.891 66.0925L881.997 123.834C879.356 126.902 874.352 125.917 872.975 122.07L847.287 49.9557C846.359 47.3559 844.132 45.4342 841.436 44.9053L766.417 30.0952C762.414 29.2988 760.713 24.4957 763.345 21.42L813.025 -36.5054C813.465 -37.0168 814.261 -37.959 815.163 -39.0246C817.156 -41.3761 815.785 -45.0493 812.712 -45.5353C811.333 -45.7567 810.081 -45.9618 809.368 -46.0702L778.196 -50.8475C775.737 -51.2183 773.271 -50.3197 771.654 -48.4419L715.727 16.5236C714.035 18.4896 713.506 21.2142 714.342 23.6873L714.333 23.6796Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1164.7 -89.7906L1122.99 -98.1936C1119.59 -98.8731 1116.78 -95.5784 1118.02 -92.3281L1133.19 -52.2419C1134.19 -49.5896 1137.66 -48.9476 1139.5 -51.081L1167.06 -83.0993C1169.1 -85.4707 1167.79 -89.1809 1164.71 -89.7994L1164.7 -89.7906Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1079.34 -163.469L1093.79 -123.4C1094.96 -120.129 1092.12 -116.865 1088.73 -117.612L1046.9 -126.719C1044.13 -127.327 1042.98 -130.666 1044.82 -132.799L1072.39 -164.818C1074.43 -167.189 1078.28 -166.442 1079.35 -163.478L1079.34 -163.469Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M967.037 -121.213L978.558 -86.7584C979.397 -84.2678 981.511 -82.397 984.081 -81.8845L1064.91 -65.7792C1067.48 -65.2665 1069.6 -63.437 1070.48 -60.9671L1098.32 16.7081C1099.21 19.1779 1101.36 21.0052 1103.95 21.4584L1139.69 27.8069C1142.26 28.2613 1144.87 27.3445 1146.56 25.3785L1202.48 -39.587C1204.09 -41.456 1204.63 -44.0395 1203.9 -46.4205L1194.58 -76.5881C1194.39 -77.2139 1193.99 -78.4479 1193.55 -79.861C1192.6 -82.849 1188.74 -83.6556 1186.74 -81.2863C1185.87 -80.2812 1185.08 -79.3643 1184.6 -78.8001L1134.7 -21.059C1132.06 -17.991 1127.06 -18.9759 1125.68 -22.8226L1099.99 -94.9369C1099.06 -97.5367 1096.84 -99.4584 1094.14 -99.9873L1019.12 -114.797C1015.12 -115.594 1013.42 -120.397 1016.05 -123.473L1065.73 -181.398C1066.17 -181.909 1066.97 -182.852 1067.87 -183.917C1069.86 -186.269 1068.49 -189.942 1065.42 -190.428C1064.04 -190.649 1062.79 -190.854 1062.07 -190.963L1030.9 -195.74C1028.44 -196.111 1025.97 -195.212 1024.36 -193.335L968.432 -128.369C966.739 -126.403 966.21 -123.678 967.046 -121.205L967.037 -121.213Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M972.308 204.959L1014.02 213.362C1017.42 214.042 1020.23 210.747 1018.99 207.497L1003.83 167.411C1002.82 164.758 999.35 164.116 997.514 166.25L969.95 198.268C967.908 200.639 969.216 204.35 972.3 204.968L972.308 204.959Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1057.67 278.638L1043.22 238.569C1042.05 235.298 1044.89 232.034 1048.28 232.78L1090.11 241.888C1092.88 242.496 1094.03 245.835 1092.19 247.968L1064.63 279.987C1062.58 282.358 1058.73 281.611 1057.66 278.647L1057.67 278.638Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1169.97 236.382L1158.45 201.927C1157.61 199.437 1155.5 197.566 1152.93 197.053L1072.1 180.948C1069.53 180.435 1067.41 178.606 1066.53 176.136L1038.69 98.4607C1037.8 95.9908 1035.65 94.1636 1033.06 93.7104L997.325 87.3618C994.751 86.9075 992.146 87.8242 990.453 89.7902L934.527 154.756C932.918 156.625 932.386 159.208 933.115 161.589L942.429 191.757C942.62 192.383 943.017 193.617 943.461 195.03C944.413 198.018 948.267 198.824 950.275 196.455C951.14 195.45 951.93 194.533 952.416 193.969L1002.31 136.228C1004.95 133.16 1009.95 134.145 1011.33 137.991L1037.02 210.106C1037.95 212.705 1040.17 214.627 1042.87 215.156L1117.89 229.966C1121.89 230.763 1123.59 235.566 1120.96 238.641L1071.28 296.567C1070.84 297.078 1070.05 298.02 1069.14 299.086C1067.15 301.437 1068.52 305.111 1071.6 305.597C1072.97 305.818 1074.23 306.023 1074.94 306.132L1106.11 310.909C1108.57 311.28 1111.04 310.381 1112.65 308.503L1168.58 243.538C1170.27 241.572 1170.8 238.847 1169.96 236.374L1169.97 236.382Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1145 55.102L1103.29 46.6989C1099.89 46.0194 1097.08 49.3142 1098.32 52.5645L1113.48 92.6507C1114.49 95.3029 1117.96 95.9449 1119.79 93.8116L1147.36 61.7933C1149.4 59.4218 1148.09 55.7117 1145.01 55.0932L1145 55.102Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M1059.64 -18.5764L1074.09 21.4922C1075.26 24.7636 1072.42 28.0277 1069.03 27.2808L1027.2 18.1733C1024.43 17.5655 1023.28 14.2265 1025.12 12.0931L1052.68 -19.9252C1054.72 -22.2966 1058.58 -21.5494 1059.65 -18.5852L1059.64 -18.5764Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M947.334 23.6796L958.855 58.1342C959.694 60.6248 961.808 62.4956 964.377 63.0081L1045.21 79.1134C1047.78 79.6261 1049.9 81.4556 1050.78 83.9254L1078.62 161.601C1079.51 164.07 1081.66 165.898 1084.25 166.351L1119.98 172.699C1122.56 173.154 1125.16 172.237 1126.85 170.271L1182.78 105.306C1184.39 103.437 1184.92 100.853 1184.19 98.4721L1174.88 68.3045C1174.69 67.6787 1174.29 66.4447 1173.85 65.0316C1172.9 62.0436 1169.04 61.237 1167.03 63.6063C1166.17 64.6113 1165.38 65.5283 1164.89 66.0925L1115 123.834C1112.36 126.902 1107.35 125.917 1105.98 122.07L1080.29 49.9557C1079.36 47.3559 1077.13 45.4342 1074.44 44.9053L999.418 30.0952C995.415 29.2988 993.714 24.4957 996.346 21.42L1046.03 -36.5054C1046.47 -37.0168 1047.26 -37.959 1048.16 -39.0246C1050.16 -41.3761 1048.79 -45.0493 1045.71 -45.5353C1044.33 -45.7567 1043.08 -45.9618 1042.37 -46.0702L1011.2 -50.8475C1008.74 -51.2183 1006.27 -50.3197 1004.66 -48.4419L948.728 16.5236C947.036 18.4896 946.507 21.2142 947.343 23.6873L947.334 23.6796Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-10.2954 -89.7842L-52.0041 -98.1873C-55.4084 -98.8668 -58.2138 -95.572 -56.976 -92.3217L-41.8127 -52.2355C-40.805 -49.5833 -37.3374 -48.9413 -35.5009 -51.0746L-7.93733 -83.093C-5.89584 -85.4644 -7.20342 -89.1745 -10.2878 -89.7931L-10.2954 -89.7842Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-95.6563 -163.463L-81.2076 -123.394C-80.0346 -120.123 -82.8755 -116.859 -86.2686 -117.605L-128.099 -126.713C-130.869 -127.321 -132.013 -130.66 -130.177 -132.793L-102.613 -164.811C-100.572 -167.183 -96.7151 -166.436 -95.6487 -163.471L-95.6563 -163.463Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-207.961 -121.207L-196.44 -86.7521C-195.602 -84.2614 -193.488 -82.3906 -190.918 -81.8782L-110.089 -65.7728C-107.519 -65.2602 -105.399 -63.4306 -104.514 -60.9608L-76.6744 16.7144C-75.7891 19.1843 -73.6365 21.0115 -71.0462 21.4648L-35.3125 27.8132C-32.7387 28.2676 -30.1333 27.3509 -28.4408 25.3849L27.486 -39.5807C29.0949 -41.4496 29.627 -44.0332 28.898 -46.4142L19.5838 -76.5818C19.3929 -77.2076 18.996 -78.4415 18.5516 -79.8546C17.6002 -82.8427 13.746 -83.6492 11.7374 -81.2799C10.8722 -80.2749 10.0828 -79.358 9.59708 -78.7937L-40.2965 -21.0526C-42.9377 -17.9846 -47.9423 -18.9695 -49.3189 -22.8162L-75.0067 -94.9306C-75.9355 -97.5303 -78.1619 -99.452 -80.8578 -99.9809L-155.877 -114.791C-159.88 -115.587 -161.581 -120.391 -158.949 -123.466L-109.269 -181.392C-108.829 -181.903 -108.033 -182.845 -107.131 -183.911C-105.138 -186.262 -106.509 -189.935 -109.582 -190.422C-110.961 -190.643 -112.213 -190.848 -112.926 -190.956L-144.098 -195.734C-146.557 -196.105 -149.023 -195.206 -150.64 -193.328L-206.567 -128.363C-208.259 -126.397 -208.788 -123.672 -207.952 -121.199L-207.961 -121.207Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-202.692 204.964L-160.984 213.367C-157.579 214.046 -154.774 210.752 -156.012 207.501L-171.175 167.415C-172.183 164.763 -175.65 164.121 -177.487 166.254L-205.05 198.272C-207.092 200.644 -205.784 204.354 -202.7 204.973L-202.692 204.964Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-117.331 278.642L-131.78 238.574C-132.953 235.302 -130.112 232.038 -126.719 232.785L-84.8883 241.892C-82.119 242.5 -80.9743 245.839 -82.8109 247.973L-110.374 279.991C-112.416 282.362 -116.273 281.615 -117.339 278.651L-117.331 278.642Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-5.0264 236.386L-16.5478 201.932C-17.386 199.441 -19.5 197.57 -22.0699 197.058L-102.898 180.952C-105.469 180.44 -107.588 178.61 -108.474 176.14L-136.313 98.4651C-137.198 95.9952 -139.351 94.168 -141.941 93.7148L-177.675 87.3662C-180.249 86.9119 -182.854 87.8286 -184.547 89.7946L-240.474 154.76C-242.083 156.629 -242.615 159.213 -241.886 161.594L-232.571 191.761C-232.381 192.387 -231.984 193.621 -231.539 195.034C-230.588 198.022 -226.734 198.829 -224.725 196.459C-223.86 195.454 -223.07 194.537 -222.585 193.973L-172.691 136.232C-170.05 133.164 -165.045 134.149 -163.669 137.996L-137.981 210.11C-137.052 212.71 -134.826 214.631 -132.13 215.16L-57.1103 229.971C-53.1076 230.767 -51.4064 235.57 -54.0387 238.646L-103.719 296.571C-104.159 297.083 -104.955 298.025 -105.856 299.09C-107.85 301.442 -106.479 305.115 -103.405 305.601C-102.027 305.822 -100.775 306.028 -100.061 306.136L-68.8898 310.913C-66.4306 311.284 -63.9642 310.385 -62.3477 308.508L-6.42088 243.542C-4.72842 241.576 -4.19972 238.852 -5.03531 236.378L-5.0264 236.386Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-30.0004 55.1083L-71.7092 46.7053C-75.1135 46.0258 -77.9188 49.3206 -76.6811 52.5708L-61.5178 92.657C-60.5101 95.3093 -57.0425 95.9513 -55.206 93.8179L-27.6424 61.7996C-25.6009 59.4282 -26.9085 55.7181 -29.9929 55.0995L-30.0004 55.1083Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-115.361 -18.57L-100.913 21.4985C-99.7397 24.7699 -102.581 28.034 -105.974 27.2872L-147.804 18.1796C-150.574 17.5719 -151.718 14.2328 -149.882 12.0995L-122.318 -19.9189C-120.277 -22.2903 -116.42 -21.543 -115.354 -18.5788L-115.361 -18.57Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M-227.666 23.6859L-216.145 58.1405C-215.307 60.6312 -213.193 62.5019 -210.623 63.0144L-129.794 79.1198C-127.224 79.6324 -125.104 81.4619 -124.219 83.9318L-96.3795 161.607C-95.4942 164.077 -93.3416 165.904 -90.7513 166.357L-55.0176 172.706C-52.4438 173.16 -49.8384 172.243 -48.1459 170.277L7.78091 105.312C9.38986 103.443 9.9219 100.859 9.19295 98.4784L-0.121262 68.3108C-0.312151 67.685 -0.709083 66.451 -1.15346 65.038C-2.10493 62.0499 -5.95909 61.2434 -7.96773 63.6127C-8.83292 64.6177 -9.62228 65.5346 -10.108 66.0988L-60.0016 123.84C-62.6428 126.908 -67.6474 125.923 -69.0239 122.076L-94.7117 49.962C-95.6405 47.3623 -97.8669 45.4406 -100.563 44.9116L-175.582 30.1016C-179.585 29.3052 -181.286 24.5021 -178.654 21.4264L-128.974 -36.4991C-128.534 -37.0104 -127.738 -37.9527 -126.836 -39.0182C-124.843 -41.3698 -126.214 -45.0429 -129.288 -45.5289C-130.666 -45.7503 -131.918 -45.9554 -132.631 -46.0639L-163.803 -50.8412C-166.262 -51.212 -168.728 -50.3134 -170.345 -48.4356L-226.272 16.5299C-227.964 18.4959 -228.493 21.2206 -227.657 23.6936L-227.666 23.6859Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M222.706 -89.7842L180.997 -98.1873C177.593 -98.8668 174.787 -95.572 176.025 -92.3217L191.188 -52.2355C192.196 -49.5833 195.663 -48.9413 197.5 -51.0746L225.064 -83.093C227.105 -85.4644 225.798 -89.1745 222.713 -89.7931L222.706 -89.7842Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M137.345 -163.463L151.793 -123.394C152.966 -120.123 150.125 -116.859 146.732 -117.605L104.902 -126.713C102.132 -127.321 100.988 -130.66 102.824 -132.793L130.388 -164.811C132.429 -167.183 136.286 -166.436 137.352 -163.471L137.345 -163.463Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M25.0397 -121.207L36.5611 -86.7521C37.3993 -84.2614 39.5133 -82.3906 42.0832 -81.8782L122.912 -65.7728C125.482 -65.2602 127.602 -63.4306 128.487 -60.9608L156.327 16.7144C157.212 19.1843 159.364 21.0115 161.955 21.4648L197.688 27.8132C200.262 28.2676 202.868 27.3509 204.56 25.3849L260.487 -39.5807C262.096 -41.4496 262.628 -44.0332 261.899 -46.4142L252.585 -76.5818C252.394 -77.2076 251.997 -78.4415 251.553 -79.8546C250.601 -82.8427 246.747 -83.6492 244.738 -81.2799C243.873 -80.2749 243.084 -79.358 242.598 -78.7937L192.704 -21.0526C190.063 -17.9846 185.059 -18.9695 183.682 -22.8162L157.994 -94.9306C157.066 -97.5303 154.839 -99.452 152.143 -99.9809L77.1236 -114.791C73.1209 -115.587 71.4197 -120.391 74.052 -123.466L123.732 -181.392C124.172 -181.903 124.968 -182.845 125.87 -183.911C127.863 -186.262 126.492 -189.935 123.418 -190.422C122.04 -190.643 120.788 -190.848 120.075 -190.956L88.9031 -195.734C86.4439 -196.105 83.9776 -195.206 82.361 -193.328L26.4342 -128.363C24.7417 -126.397 24.213 -123.672 25.0486 -121.199L25.0397 -121.207Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M30.3106 204.966L72.0193 213.369C75.4237 214.048 78.229 210.753 76.9913 207.503L61.828 167.417C60.8203 164.765 57.3527 164.123 55.5162 166.256L27.9526 198.274C25.9111 200.646 27.2187 204.356 30.303 204.975L30.3106 204.966Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M115.672 278.644L101.223 238.576C100.05 235.304 102.891 232.04 106.284 232.787L148.115 241.894C150.884 242.502 152.029 245.841 150.192 247.975L122.628 279.993C120.587 282.364 116.73 281.617 115.664 278.653L115.672 278.644Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M227.977 236.388L216.455 201.934C215.617 199.443 213.503 197.572 210.933 197.06L130.104 180.954C127.534 180.442 125.415 178.612 124.529 176.142L96.6897 98.467C95.8044 95.9972 93.6518 94.17 91.0615 93.7167L55.3278 87.3682C52.754 86.9138 50.1486 87.8306 48.4561 89.7966L-7.47073 154.762C-9.07968 156.631 -9.61172 159.215 -8.88277 161.596L0.431442 191.763C0.622332 192.389 1.01926 193.623 1.46364 195.036C2.41511 198.024 6.26927 198.831 8.27791 196.461C9.1431 195.456 9.93247 194.539 10.4182 193.975L60.3118 136.234C62.953 133.166 67.9575 134.151 69.3341 137.998L95.0219 210.112C95.9507 212.712 98.1771 214.633 100.873 215.162L175.893 229.972C179.895 230.769 181.597 235.572 178.964 238.648L129.284 296.573C128.844 297.084 128.048 298.027 127.147 299.092C125.153 301.444 126.524 305.117 129.598 305.603C130.976 305.824 132.228 306.029 132.942 306.138L164.113 310.915C166.572 311.286 169.039 310.387 170.655 308.51L226.582 243.544C228.274 241.578 228.803 238.853 227.968 236.38L227.977 236.388Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M203.002 55.1083L161.294 46.7053C157.889 46.0258 155.084 49.3206 156.322 52.5708L171.485 92.657C172.493 95.3093 175.96 95.9513 177.797 93.8179L205.361 61.7996C207.402 59.4282 206.094 55.7181 203.01 55.0995L203.002 55.1083Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M117.642 -18.57L132.09 21.4985C133.263 24.7699 130.422 28.034 127.029 27.2872L85.1985 18.1796C82.4292 17.5719 81.2845 14.2328 83.121 12.0995L110.685 -19.9189C112.726 -22.2903 116.583 -21.543 117.649 -18.5788L117.642 -18.57Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
<path
d="M5.33659 23.6859L16.858 58.1405C17.6961 60.6312 19.8102 62.5019 22.3801 63.0144L103.209 79.1198C105.779 79.6324 107.898 81.4619 108.784 83.9318L136.623 161.607C137.509 164.077 139.661 165.904 142.252 166.357L177.985 172.706C180.559 173.16 183.165 172.243 184.857 170.277L240.784 105.312C242.393 103.443 242.925 100.859 242.196 98.4784L232.882 68.3108C232.691 67.685 232.294 66.451 231.849 65.038C230.898 62.0499 227.044 61.2434 225.035 63.6127C224.17 64.6177 223.381 65.5346 222.895 66.0988L173.001 123.84C170.36 126.908 165.356 125.923 163.979 122.076L138.291 49.962C137.362 47.3623 135.136 45.4406 132.44 44.9116L57.4205 30.1016C53.4178 29.3052 51.7166 24.5021 54.3488 21.4264L104.029 -36.4991C104.469 -37.0104 105.265 -37.9527 106.167 -39.0182C108.16 -41.3698 106.789 -45.0429 103.715 -45.5289C102.337 -45.7503 101.085 -45.9554 100.372 -46.0639L69.2 -50.8412C66.7408 -51.212 64.2744 -50.3134 62.6579 -48.4356L6.73106 16.5299C5.0386 18.4959 4.5099 21.2206 5.34549 23.6936L5.33659 23.6859Z"
stroke="currentColor" stroke-opacity="0.18" stroke-width="2"
mask="url(#path-1-inside-1_9092_23263)" />
</svg>

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,12 @@
<svg viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="72" height="72" rx="12" fill="currentColor" fill-opacity="0.12" />
<path
d="M22.562 46.8959L31.189 42.2804C31.8922 41.9026 31.8873 40.9333 31.1763 40.558L22.426 35.9147C21.8463 35.6082 21.1377 36.0016 21.1373 36.6321L21.1319 46.0958C21.1315 46.7967 21.9244 47.2404 22.562 46.8985L22.562 46.8959Z"
fill="currentColor" />
<path
d="M48.7804 47.0911L40.1588 42.3472C39.4561 41.9589 39.4621 40.9896 40.1735 40.625L48.9288 36.112C49.5092 35.8141 50.2172 36.218 50.2168 36.8485L50.2114 46.3122C50.211 47.0131 49.4178 47.445 48.7804 47.0937L48.7804 47.0911Z"
fill="currentColor" />
<path
d="M62.1735 23.588L54.919 19.3778C54.3937 19.0742 53.7374 19.0615 53.2066 19.3468L36.5152 28.3219C35.9844 28.6072 35.3333 28.6024 34.8028 28.3092L18.1193 19.0882C17.5888 18.7951 16.9323 18.7954 16.4069 19.0937L9.14477 23.1933C8.62214 23.4891 8.30179 24.0235 8.30145 24.6046L8.29042 43.8064C8.2901 44.3589 8.58797 44.877 9.07744 45.1777L15.2929 48.9763C15.4214 49.0554 15.6784 49.2085 15.971 49.3853C16.5917 49.7573 17.3935 49.3359 17.3884 48.6401C17.3886 48.3431 17.3887 48.0721 17.3888 47.9053L17.4315 30.8117C17.432 29.9049 18.4636 29.3471 19.2894 29.8041L34.7506 38.3879C35.3084 38.697 35.9922 38.7021 36.5477 38.4013L52.0186 30.0477C52.845 29.603 53.8731 30.1762 53.8753 31.083L53.8983 48.177C53.8982 48.3282 53.9008 48.6044 53.9033 48.9171C53.9084 49.6077 54.707 50.0358 55.3225 49.6729C55.599 49.5108 55.8509 49.3642 55.9931 49.2792L62.2128 45.5732C62.7025 45.2798 63.0011 44.7687 63.0014 44.2137L63.0125 25.0118C63.0128 24.4307 62.693 23.8889 62.1707 23.588L62.1735 23.588Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -197,13 +197,13 @@
}
> :where(
input + *,
.input-group + *,
.textarea-wrapper + *,
.chips + *,
.resizable-textarea-wrapper + *,
.input-div + *
) {
input + *,
.input-group + *,
.textarea-wrapper + *,
.chips + *,
.resizable-textarea-wrapper + *,
.input-div + *
) {
&:not(:empty) {
margin-block-start: var(--spacing-card-md);
}

View File

@@ -115,10 +115,12 @@ html {
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
--shadow-raised:
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
@@ -150,8 +152,8 @@ html {
rgba(255, 255, 255, 0.35) 0%,
rgba(255, 255, 255, 0.2695) 100%
);
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
inset 2px 2px 64px rgba(255, 255, 255, 0.45);
--landing-blob-shadow:
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
--landing-card-bg: rgba(255, 255, 255, 0.8);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -163,6 +165,11 @@ html {
--landing-raw-bg: #fff;
--medal-promotion-bg: #fff;
--medal-promotion-bg-orange: #c08a3a;
--medal-promotion-text-orange: #a86200;
--medal-promotion-bg-gradient: linear-gradient(90deg, rgba(255, 184, 75, 0.15) 0%, #fff 100%);
--banner-error-bg: #fee2e2;
--banner-error-text: #991b1b;
--banner-error-border: #ef4444;
@@ -251,13 +258,15 @@ html {
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
--landing-maze-gradient-bg:
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
@@ -284,7 +293,8 @@ html {
rgba(44, 48, 79, 0.35) 0%,
rgba(32, 35, 50, 0.2695) 100%
);
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-blob-shadow:
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-card-bg: rgba(59, 63, 85, 0.15);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -296,6 +306,11 @@ html {
--landing-raw-bg: #000;
--medal-promotion-bg: #000;
--medal-promotion-bg-orange: #ffb84b54;
--medal-promotion-text-orange: #ffb84b;
--medal-promotion-bg-gradient: linear-gradient(90deg, #ffb74b21, transparent 50%, #000 100%);
--hover-filter: brightness(120%);
--active-filter: brightness(140%);
@@ -360,8 +375,9 @@ body {
// Defaults
background-color: var(--color-bg);
color: var(--color-text);
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--font-standard:
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard);
font-size: 16px;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,10 @@
<template>
<div
class="vue-notification-group experimental-styles-within"
:class="{ 'intercom-present': isIntercomPresent }"
:class="{
'intercom-present': isIntercomPresent,
rightwards: moveNotificationsRight,
}"
>
<transition-group name="notifs">
<div
@@ -82,6 +85,7 @@ import {
CopyIcon,
} from "@modrinth/assets";
const notifications = useNotifications();
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
const isIntercomPresent = ref(false);
@@ -160,6 +164,15 @@ function copyToClipboard(notif) {
bottom: 5rem;
}
&.rightwards {
right: unset !important;
left: 1.5rem;
@media screen and (max-width: 500px) {
left: 0.75rem;
}
}
.vue-notification-wrapper {
width: 100%;
overflow: hidden;

View File

@@ -0,0 +1,179 @@
<template>
<div class="universal-card">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
Score: {{ report.priority_score }}
</span>
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
:class="{
'text-brand': report.status === 'approved',
'text-red': report.status === 'rejected',
'text-secondary': report.status === 'pending',
}"
>
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
</span>
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
{{
report.version.files.find((file) => file.primary)?.filename ||
"Unknown primary file"
}}
</span>
</div>
</div>
</div>
</div>
<div
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
>
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</span>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex gap-2">
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Accept
</button>
</ButtonStyled>
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Reject
</button>
</ButtonStyled>
</div>
<div class="flex justify-center gap-2 sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="versionUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div class="text-sm text-secondary sm:hidden">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import {
Avatar,
useRelativeTime,
OverflowMenu,
type OverflowMenuOption,
ButtonStyled,
} from "@modrinth/ui";
import {
EllipsisVerticalIcon,
OrganizationIcon,
EyeIcon,
ClipboardCopyIcon,
LinkIcon,
} from "@modrinth/assets";
import type { ExtendedDelphiReport } from "@modrinth/moderation";
const props = defineProps<{
report: ExtendedDelphiReport;
}>();
const formatRelativeTime = useRelativeTime();
const isPending = computed(() => props.report.status === "pending");
const quickActions: OverflowMenuOption[] = [
{
id: "copy-link",
action: () => {
const base = window.location.origin;
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`;
navigator.clipboard.writeText(reviewUrl).then(() => {
addNotification({
type: "success",
title: "Tech review link copied",
text: "The link to this tech review has been copied to your clipboard.",
});
});
},
},
{
id: "copy-id",
action: () => {
navigator.clipboard.writeText(props.report.version.id).then(() => {
addNotification({
type: "success",
title: "Version ID copied",
text: "The ID of this version has been copied to your clipboard.",
});
});
},
},
];
const versionUrl = computed(() => {
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`;
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,204 @@
<template>
<div
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex-shrink-0 rounded-lg">
<Avatar size="48px" :src="queueEntry.project.icon_url" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<h3 class="truncate text-lg font-semibold">
{{ queueEntry.project.name }}
</h3>
<nuxt-link
v-if="queueEntry.owner"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/user/${queueEntry.owner.user.username}`"
>
<Avatar
:src="queueEntry.owner.user.avatar_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="queueEntry.org"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/organization/${queueEntry.org.slug}`"
>
<Avatar
:src="queueEntry.org.icon_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.org.name }}</span>
</nuxt-link>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
<BoxIcon
v-if="queueEntry.project.project_type === 'mod'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="queueEntry.project.project_type === 'resourcepack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<BracesIcon
v-else-if="queueEntry.project.project_type === 'datapack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="queueEntry.project.project_type === 'modpack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="queueEntry.project.project_type === 'shader'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PlugIcon
v-else-if="queueEntry.project.project_type === 'plugin'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<span class="hidden sm:inline">{{
props.queueEntry.project.project_types.map(formatProjectType).join(", ")
}}</span>
<span class="sm:hidden">{{
formatProjectType(props.queueEntry.project.project_type ?? "project").substring(0, 3)
}}</span>
</span>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<div class="flex flex-row gap-2 text-sm">
Requesting
<Badge
v-if="props.queueEntry.project.requested_status"
:type="props.queueEntry.project.requested_status"
class="status"
/>
</div>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<span
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
class="truncate text-sm"
:class="{
'text-red': daysInQueue > 4,
'text-orange': daysInQueue > 2,
}"
>
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
<span class="sm:hidden">{{
getSubmittedTime(queueEntry).replace("Submitted ", "")
}}</span>
</span>
</div>
<div class="flex items-center justify-end gap-2 sm:justify-start">
<ButtonStyled circular>
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
<EyeIcon class="size-4" />
</NuxtLink>
</ButtonStyled>
<ButtonStyled circular color="orange" @click="openProjectForReview">
<button>
<ScaleIcon class="size-4" />
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import {
EyeIcon,
PaintbrushIcon,
ScaleIcon,
BoxIcon,
GlassesIcon,
PlugIcon,
PackageOpenIcon,
BracesIcon,
} from "@modrinth/assets";
import { useRelativeTime, Avatar, ButtonStyled, Badge } from "@modrinth/ui";
import {
formatProjectType,
type Organization,
type Project,
type TeamMember,
} from "@modrinth/utils";
import { computed } from "vue";
import { useModerationStore } from "~/store/moderation.ts";
import type { ModerationProject } from "~/helpers/moderation";
const formatRelativeTime = useRelativeTime();
const moderationStore = useModerationStore();
const props = defineProps<{
queueEntry: ModerationProject;
}>();
function getDaysQueued(date: Date): number {
const now = new Date();
const diff = now.getTime() - date.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
const queuedDate = computed(() => {
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated,
);
});
const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate());
});
function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id);
navigateTo({
name: "type-id",
params: {
type: "project",
id: props.queueEntry.project.id,
},
state: {
showChecklist: true,
},
});
}
function getSubmittedTime(project: any): string {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated;
if (!date) return "Unknown";
try {
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`;
} catch {
return "Unknown";
}
}
</script>

View File

@@ -0,0 +1,275 @@
<template>
<div class="universal-card">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="flex items-center gap-2">
Reported for
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
{{ formattedReportType }}
</span>
</span>
<span class="flex items-center gap-2">
<span class="hidden sm:inline">By</span>
<span class="sm:hidden">Reporter:</span>
<nuxt-link
:to="`/user/${report.reporter_user.username}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.reporter_user.avatar_url"
circle
size="1.75rem"
class="flex-shrink-0"
/>
<span class="truncate">{{ report.reporter_user.username }}</span>
</nuxt-link>
</span>
</span>
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
<span class="text-md whitespace-nowrap text-secondary">{{
formatRelativeTime(report.created)
}}</span>
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
<OverflowMenu :options="visibleQuickReplies">
<span class="hidden sm:inline">Quick Reply</span>
<span class="sr-only sm:hidden">Quick Reply</span>
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<hr class="my-4 rounded-xl border-solid text-divider" />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="3rem"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target && report.item_type != 'user'"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target?.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name || "Unknown User" }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
{{ formattedItemType }}
</span>
<span
v-if="report.item_type === 'version' && report.version"
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
>
{{
report.version.files.find((file) => file.primary)?.filename || "Unknown Version"
}}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="reportItemUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<CollapsibleRegion class="my-4" ref="collapsibleRegion">
<ReportThread
v-if="report.thread"
ref="reportThread"
class="mb-16 sm:mb-0"
:thread="report.thread"
:report="report"
:reporter="report.reporter_user"
@update-thread="updateThread"
/>
</CollapsibleRegion>
</div>
</template>
<script setup lang="ts">
import {
Avatar,
useRelativeTime,
OverflowMenu,
type OverflowMenuOption,
CollapsibleRegion,
ButtonStyled,
} from "@modrinth/ui";
import {
EllipsisVerticalIcon,
OrganizationIcon,
EyeIcon,
ClipboardCopyIcon,
LinkIcon,
} from "@modrinth/assets";
import {
type ExtendedReport,
reportQuickReplies,
type ReportQuickReply,
} from "@modrinth/moderation";
import ChevronDownIcon from "../servers/icons/ChevronDownIcon.vue";
import ReportThread from "../thread/ReportThread.vue";
const props = defineProps<{
report: ExtendedReport;
}>();
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null);
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null);
const formatRelativeTime = useRelativeTime();
function updateThread(newThread: any) {
if (props.report.thread) {
Object.assign(props.report.thread, newThread);
}
}
const quickActions: OverflowMenuOption[] = [
{
id: "copy-link",
action: () => {
const base = window.location.origin;
const reportUrl = `${base}/moderation/reports/${props.report.id}`;
navigator.clipboard.writeText(reportUrl).then(() => {
addNotification({
type: "success",
title: "Report link copied",
text: "The link to this report has been copied to your clipboard.",
});
});
},
},
{
id: "copy-id",
action: () => {
navigator.clipboard.writeText(props.report.id).then(() => {
addNotification({
type: "success",
title: "Report ID copied",
text: "The ID of this report has been copied to your clipboard.",
});
});
},
},
];
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
return reportQuickReplies
.filter((reply) => {
if (reply.shouldShow === undefined) return true;
if (typeof reply.shouldShow === "function") {
return reply.shouldShow(props.report);
}
return reply.shouldShow;
})
.map(
(reply) =>
({
id: reply.label,
action: () => handleQuickReply(reply),
}) as OverflowMenuOption,
);
});
async function handleQuickReply(reply: ReportQuickReply) {
const message =
typeof reply.message === "function" ? await reply.message(props.report) : reply.message;
collapsibleRegion.value?.setCollapsed(false);
await nextTick();
reportThread.value?.setReplyContent(message);
}
const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) {
case "project":
case "version":
return props.report.project?.icon_url || "";
case "user":
return props.report.user?.avatar_url || "";
default:
return undefined;
}
});
const reportItemTitle = computed(() => {
if (props.report.item_type === "user") return props.report.user?.username || "Unknown User";
return props.report.project?.title || "Unknown Project";
});
const reportItemUrl = computed(() => {
switch (props.report.item_type) {
case "user":
return `/user/${props.report.user?.username}`;
case "project":
return `/${props.report.project?.project_type}/${props.report.project?.slug}`;
case "version":
return `/${props.report.project?.project_type}/${props.report.project?.slug}/versions/${props.report.version?.id}`;
}
});
const formattedItemType = computed(() => {
const itemType = props.report.item_type;
return itemType.charAt(0).toUpperCase() + itemType.slice(1);
});
const formattedReportType = computed(() => {
const reportType = props.report.report_type;
// some are split by -, some are split by " "
const words = reportType.includes("-") ? reportType.split("-") : reportType.split(" ");
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,116 @@
<template>
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
<div>
<div class="keybinds-sections">
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
<div
v-for="keybind in keybinds"
:key="keybind.id"
class="keybind-item flex items-center justify-between gap-4"
:class="{
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
}"
>
<span class="text-sm text-secondary">{{ keybind.description }}</span>
<div class="flex items-center gap-1">
<kbd
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
:key="`${keybind.id}-key-${index}`"
class="keybind-key"
>
{{ key }}
</kbd>
</div>
</div>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref } from "vue";
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
const modal = ref<InstanceType<typeof NewModal>>();
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
const normalized = keybinds[0];
const def = normalizeKeybind(normalized);
const keys = [];
if (def.ctrl || def.meta) {
keys.push(isMac() ? "CMD" : "CTRL");
}
if (def.shift) keys.push("SHIFT");
if (def.alt) keys.push("ALT");
const mainKey = def.key
.replace("ArrowLeft", "←")
.replace("ArrowRight", "→")
.replace("ArrowUp", "↑")
.replace("ArrowDown", "↓")
.replace("Enter", "↵")
.replace("Space", "SPACE")
.replace("Escape", "ESC")
.toUpperCase();
keys.push(mainKey);
return keys;
}
function isMac() {
return navigator.platform.toUpperCase().includes("MAC");
}
function show(event?: MouseEvent) {
modal.value?.show(event);
}
function hide() {
modal.value?.hide();
}
defineExpose({
show,
hide,
});
</script>
<style scoped lang="scss">
.keybind-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-bg);
border: 1px solid var(--color-divider);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-contrast);
+ .keybind-key {
margin-left: 0.25rem;
}
}
.keybind-item {
min-height: 2rem;
}
@media (max-width: 768px) {
.keybinds-sections {
.grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,513 @@
<template>
<div>
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
{{ modPackData.length }})
</h2>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions already obtained.</p>
</div>
<div v-else-if="!modPackData[currentIndex]">
<p>All permission checks complete!</p>
</div>
<div v-else>
<div v-if="modPackData[currentIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
@input="persistAll()"
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
@input="persistAll()"
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
@input="persistAll()"
/>
</div>
</div>
<div v-else-if="modPackData[currentIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[currentIndex].title }} (<a
:href="modPackData[currentIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[currentIndex].url }}</a
>)?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(
modPackData[currentIndex].status || '',
)
"
>
<p v-if="modPackData[currentIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in filePermissionTypes"
:key="index"
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
@click="setApproval(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="mt-4 flex gap-2">
<ButtonStyled>
<button :disabled="currentIndex <= 0" @click="goToPrevious">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
<button :disabled="!canGoNext" @click="goToNext">
<RightArrowIcon aria-hidden="true" />
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
import type {
ModerationJudgements,
ModerationModpackItem,
ModerationModpackResponse,
ModerationUnknownModpackItem,
ModerationFlameModpackItem,
ModerationModpackPermissionApprovalType,
ModerationPermissionType,
} from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue";
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
const props = defineProps<{
projectId: string;
modelValue?: ModerationJudgements;
}>();
const emit = defineEmits<{
complete: [];
"update:modelValue": [judgements: ModerationJudgements];
}>();
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
`modpack-permissions-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
`modpack-permissions-data-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
`modpack-permissions-permanent-no-${props.projectId}`,
[],
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : []),
write: (v: any) => JSON.stringify(v),
},
},
);
const currentIndex = ref(0);
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
{
id: "yes",
name: "Yes",
},
{
id: "with-attribution-and-source",
name: "With attribution and source",
},
{
id: "with-attribution",
name: "With attribution",
},
{
id: "no",
name: "No",
},
{
id: "permanent-no",
name: "Permanent no",
},
{
id: "unidentified",
name: "Unidentified",
},
];
const filePermissionTypes: ModerationPermissionType[] = [
{ id: "yes", name: "Yes" },
{ id: "no", name: "No" },
];
function persistAll() {
persistedModPackData.value = modPackData.value;
persistedIndex.value = currentIndex.value;
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
watch(currentIndex, (newValue) => {
persistedIndex.value = newValue;
});
function loadPersistedData(): void {
if (persistedModPackData.value) {
modPackData.value = persistedModPackData.value;
}
currentIndex.value = persistedIndex.value;
}
function clearPersistedData(): void {
persistedModPackData.value = null;
persistedIndex.value = 0;
}
async function fetchModPackData(): Promise<void> {
try {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === "permanent-no")
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name));
permanentNoFiles.value = permanentNoItems;
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {})
.filter(
([_, file]) =>
file.status !== "yes" &&
file.status !== "with-attribution-and-source" &&
file.status !== "permanent-no",
)
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
...(file.status === "unidentified" && {
proof: "",
url: "",
title: "",
}),
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
sha1,
file_name: fileName,
type: "unknown",
status: null,
approved: null,
proof: "",
url: "",
title: "",
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.flame_files || {})
.map(
([sha1, info]): ModerationFlameModpackItem => ({
sha1,
file_name: info.file_name,
type: "flame",
status: null,
approved: null,
id: info.id,
title: info.title || info.file_name,
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
];
if (modPackData.value) {
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
sortedData.forEach((item) => {
const existing = existingMap.get(item.sha1);
if (existing) {
Object.assign(item, {
status: existing.status,
approved: existing.approved,
...(item.type === "unknown" && {
proof: (existing as ModerationUnknownModpackItem).proof || "",
url: (existing as ModerationUnknownModpackItem).url || "",
title: (existing as ModerationUnknownModpackItem).title || "",
}),
...(item.type === "flame" && {
url: (existing as ModerationFlameModpackItem).url || item.url,
title: (existing as ModerationFlameModpackItem).title || item.title,
}),
});
}
});
}
modPackData.value = sortedData;
persistAll();
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
permanentNoFiles.value = [];
persistAll();
}
}
function goToPrevious(): void {
if (currentIndex.value > 0) {
currentIndex.value--;
persistAll();
}
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
if (currentIndex.value >= modPackData.value.length) {
const judgements = getJudgements();
emit("update:modelValue", judgements);
emit("complete");
clearPersistedData();
} else {
persistAll();
}
}
}
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].status = status;
modPackData.value[index].approved = null;
persistAll();
emit("update:modelValue", getJudgements());
}
}
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].approved = approved;
persistAll();
emit("update:modelValue", getJudgements());
}
}
const canGoNext = computed(() => {
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
const current = modPackData.value[currentIndex.value];
return current.status !== null;
});
function getJudgements(): ModerationJudgements {
if (!modPackData.value) return {};
const judgements: ModerationJudgements = {};
modPackData.value.forEach((item) => {
if (item.type === "flame") {
judgements[item.sha1] = {
type: "flame",
id: item.id,
status: item.status,
link: item.url,
title: item.title,
file_name: item.file_name,
};
} else if (item.type === "unknown") {
judgements[item.sha1] = {
type: "unknown",
status: item.status,
proof: item.proof,
link: item.url,
title: item.title,
file_name: item.file_name,
};
}
});
return judgements;
}
onMounted(() => {
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
});
watch(
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit("complete");
clearPersistedData();
}
},
{ immediate: true },
);
watch(
() => props.projectId,
() => {
clearPersistedData();
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
},
);
function getModpackFiles(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
};
}
defineExpose({
getModpackFiles,
});
</script>
<style scoped>
.input-group {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.modpack-buttons {
margin-top: 1rem;
}
</style>

View File

@@ -172,6 +172,7 @@ const flags = useFeatureFlags();
.markdown-body {
grid-area: body;
max-width: 100%;
}
.reporter-info {

View File

@@ -1,13 +1,21 @@
<template>
<template v-if="moderation">
<Chips v-model="reasonFilter" :items="reasons" />
<p v-if="reports.length === MAX_REPORTS" class="text-red">
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
not show any more recent ones.
</p>
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
<p v-else>
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
</p>
</template>
<ReportInfo
v-for="report in reports.filter(
(x) =>
(moderation || x.reporterUser.id === auth.user.id) &&
(viewMode === 'open' ? x.open : !x.open),
)"
v-for="report in filteredReports"
:key="report.id"
:report="report"
:thread="report.thread"
:show-message="false"
:moderation="moderation"
raised
:auth="auth"
@@ -16,11 +24,12 @@
<p v-if="reports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import { Chips } from "@modrinth/ui";
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
defineProps({
const props = defineProps({
moderation: {
type: Boolean,
default: false,
@@ -32,9 +41,14 @@ defineProps({
});
const viewMode = ref("open");
const reasonFilter = ref("All");
const reports = ref([]);
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
const MAX_REPORTS = 1500;
let { data: rawReports } = await useAsyncData("report", () =>
useBaseFetch(`report?count=${MAX_REPORTS}`),
);
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, "");
@@ -51,6 +65,7 @@ const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
];
const reasons = ["All", ...new Set(rawReports.map((report) => report.report_type))];
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
@@ -93,4 +108,13 @@ reports.value = rawReports.map((report) => {
report.open = true;
return report;
});
const filteredReports = computed(() =>
reports.value?.filter(
(x) =>
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
(viewMode.value === "open" ? x.open : !x.open) &&
(reasonFilter.value === "All" || reasonFilter.value === x.report_type),
),
);
</script>

View File

@@ -31,9 +31,9 @@
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
@click="
versionFilter &&
(unlockFilterAccordion.isOpen
? unlockFilterAccordion.close()
: unlockFilterAccordion.open())
(unlockFilterAccordion.isOpen
? unlockFilterAccordion.close()
: unlockFilterAccordion.open())
"
>
<TagItem

View File

@@ -1,6 +1,6 @@
<template>
<div
class="experimental-styles-within flex size-24 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
class="experimental-styles-within flex size-16 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<client-only>
<img

View File

@@ -4,26 +4,19 @@
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
>
<div
v-tooltip="
status === 'suspended'
? suspension_reason === 'upgrading'
? 'This server is being transferred to a new node. It will be unavailable until this process finishes.'
: 'This server has been suspended. Please visit your billing settings or contact Modrinth Support for more information.'
: ''
"
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100"
:class="status === 'suspended' ? '!rounded-b-none opacity-75' : 'active:scale-95'"
class="flex flex-row items-center overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4 transition-transform duration-100"
:class="status === 'suspended' ? '!rounded-b-none border-b-0 opacity-75' : 'active:scale-95'"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
>
<UiServersServerIcon v-if="status !== 'suspended'" :image="image" />
<div
v-else
class="bg-bg-secondary flex size-24 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
class="bg-bg-secondary flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<LockIcon class="size-20 text-secondary" />
<LockIcon class="size-12 text-secondary" />
</div>
<div class="ml-8 flex flex-col gap-2.5">
<div class="ml-4 flex flex-col gap-2.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
<ChevronRightIcon />
@@ -41,7 +34,6 @@
/>
Using {{ projectData?.title || "Unknown" }}
</div>
<div v-else class="min-h-[20px]"></div>
<div
v-if="isConfiguring"
@@ -61,14 +53,35 @@
</div>
<div
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-blue p-4 text-sm font-bold text-contrast"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-blue bg-bg-blue p-4 text-sm font-bold text-contrast"
>
<UiServersPanelSpinner />
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<div
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been cancelled. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended' && suspension_reason"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended:
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended'"
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"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
@@ -82,12 +95,13 @@
<script setup lang="ts">
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
import type { Project, Server } from "@modrinth/utils";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import { Avatar, CopyCode } from "@modrinth/ui";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<Partial<Server>>();
if (props.server_id) {
if (props.server_id && props.status === "available") {
// Necessary only to get server icon
await useModrinthServers(props.server_id, ["general"]);
}
@@ -109,11 +123,6 @@ if (props.upstream) {
}
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
if (import.meta.server && projectData.value?.icon_url) {
await useModrinthServers(props.server_id!, ["general"]);
}
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
const isConfiguring = computed(() => props.flows?.intro);
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div
id="medal"
class="medal-promotion flex w-full flex-row justify-between rounded-xl p-6 shadow-xl"
>
<div class="overlay"></div>
<MedalPromoBackground class="background-pattern shadow-xl" />
<div class="z-10 flex flex-col gap-2">
<div class="flex items-center gap-2 text-2xl font-semibold text-contrast">
<ClockIcon class="clock-glow text-medal-orange size-6" /><span>
Try a free
<span class="text-medal-orange">3GB server</span> for 5 days powered by
<span class="text-medal-orange">Medal</span>
</span>
</div>
<div class="flex items-center">
<span class="text-sm text-secondary"
>Limited-time offer. No credit card required. Available for US servers.</span
>
</div>
</div>
<ButtonStyled color="orange" type="outlined" size="large">
<nuxt-link to="https://medal.tv/" class="z-10 my-auto">Learn more <ExternalIcon /></nuxt-link>
</ButtonStyled>
</div>
</template>
<script lang="ts" setup>
import { ClockIcon, ExternalIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import MedalPromoBackground from "~/assets/images/illustrations/medal_promo_background.svg?component";
</script>
<style scoped lang="scss">
.medal-promotion {
position: relative;
border: 1px solid var(--medal-promotion-bg-orange);
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--medal-promotion-bg-gradient);
z-index: 1;
border-radius: inherit;
}
.background-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background-color: var(--medal-promotion-bg);
border-radius: inherit;
color: var(--color-orange);
}
.clock-glow {
filter: drop-shadow(0 0 72px var(--color-orange)) drop-shadow(0 0 36px var(--color-orange))
drop-shadow(0 0 18px var(--color-orange));
}
.text-medal-orange {
color: var(--medal-promotion-text-orange);
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div
class="medal-promotion flex w-full flex-row items-center justify-between rounded-2xl p-4 shadow-xl"
>
<div class="overlay"></div>
<MedalPromoBackground class="background-pattern scale-[125%]" />
<div class="z-10 flex flex-col gap-1">
<div class="flex items-center gap-2 text-lg font-semibold text-contrast">
<ClockIcon class="clock-glow text-medal-orange size-5" />
<span>
Your <span class="text-medal-orange">Medal</span> powered Modrinth Server will expire in
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.days }}</span> days
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.hours }}</span> hours
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.minutes }}</span> minutes
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.seconds }}</span> seconds.
</span>
</div>
</div>
<ButtonStyled color="orange" type="outlined" size="large">
<button class="z-10 my-auto" @click="handleUpgrade"><RocketIcon /> Upgrade</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ClockIcon, RocketIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import dayjs from "dayjs";
import dayjsDuration from "dayjs/plugin/duration";
import MedalPromoBackground from "~/assets/images/illustrations/medal_promo_background.svg?component";
// eslint-disable-next-line import/no-named-as-default-member
dayjs.extend(dayjsDuration);
const props = defineProps<{
expiryDate?: string | Date;
}>();
const expiryDate = computed(() => {
if (props.expiryDate) {
return dayjs(props.expiryDate);
}
return dayjs().add(5, "day");
});
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 });
function handleUpgrade(event: Event) {
event.stopPropagation();
// TODO: Upgrade logic
}
function updateCountdown() {
const now = dayjs();
const diff = expiryDate.value.diff(now);
if (diff <= 0) {
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 };
return;
}
const duration = dayjs.duration(diff);
timeLeftCountdown.value = {
days: duration.days(),
hours: duration.hours(),
minutes: duration.minutes(),
seconds: duration.seconds(),
};
}
updateCountdown();
const intervalId = ref<NodeJS.Timeout | null>(null);
onMounted(() => {
intervalId.value = setInterval(updateCountdown, 1000);
});
onUnmounted(() => {
if (intervalId.value) clearInterval(intervalId.value);
});
</script>
<style scoped lang="scss">
.medal-promotion {
position: relative;
border: 1px solid var(--medal-promotion-bg-orange);
background: inherit; // allows overlay + pattern to take over
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--medal-promotion-bg-gradient);
z-index: 1;
border-radius: inherit;
}
.background-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background-color: var(--medal-promotion-bg);
border-radius: inherit;
color: var(--medal-promotion-text-orange);
}
.clock-glow {
filter: drop-shadow(0 0 72px var(--color-orange)) drop-shadow(0 0 36px var(--color-orange))
drop-shadow(0 0 18px var(--color-orange));
}
.text-medal-orange {
color: var(--medal-promotion-text-orange);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,243 @@
<template>
<div>
<div
class="medal-promotion flex flex-row items-center overflow-x-hidden rounded-2xl p-4 shadow-xl transition-transform duration-100"
:class="status === 'suspended' ? '!rounded-b-none border-b-0 opacity-75' : ''"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
>
<div class="overlay"></div>
<MedalPromoBackground class="background-pattern scale-[125%]" />
<NuxtLink
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
class="z-10 flex flex-grow flex-row items-center overflow-x-hidden"
:class="status !== 'suspended' && 'active:scale-95'"
>
<MedalServerIcon
v-if="status !== 'suspended'"
class="border-medal-orange z-10 size-16 shrink-0 rounded-xl border-[1px] border-solid bg-bg text-orange"
/>
<div
v-else
class="bg-bg-secondary z-10 flex size-16 shrink-0 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<LockIcon class="size-12 text-secondary" />
</div>
<div class="z-10 ml-4 flex min-w-0 flex-col gap-2.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 truncate text-xl font-bold text-contrast">{{ name }}</h2>
<ChevronRightIcon />
<span class="truncate">
<span class="text-medal-orange">
{{ timeLeftCountdown.days }}
</span>
days
<span class="text-medal-orange">
{{ timeLeftCountdown.hours }}
</span>
hours
<span class="text-medal-orange">
{{ timeLeftCountdown.minutes }}
</span>
minutes
<span class="text-medal-orange">
{{ timeLeftCountdown.seconds }}
</span>
seconds remaining...
</span>
</div>
<div
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
>
<Avatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
alt="Server Icon"
/>
Using {{ projectData?.title || "Unknown" }}
</div>
<div
v-if="isConfiguring"
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
>
<SparklesIcon class="size-5 shrink-0" /> New server
</div>
<UiServersServerInfoLabels
v-else
:server-data="{ game, mc_version, loader, loader_version, net }"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
: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"
/>
</div>
</NuxtLink>
<div class="z-10 ml-auto mr-6">
<ButtonStyled color="orange" type="outlined" size="large">
<button class="my-auto" @click="handleUpgrade"><RocketIcon /> Upgrade</button>
</ButtonStyled>
</div>
</div>
<div
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-blue bg-bg-blue p-4 text-sm font-bold text-contrast"
>
<UiServersPanelSpinner />
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<div
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been cancelled. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended' && suspension_reason"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended:
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon, LockIcon, SparklesIcon, RocketIcon } from "@modrinth/assets";
import type { Project, Server } from "@modrinth/utils";
import { Avatar, CopyCode, ButtonStyled } from "@modrinth/ui";
import dayjs from "dayjs";
import dayjsDuration from "dayjs/plugin/duration";
import MedalServerIcon from "~/assets/images/servers/medal_server_icon.svg?component";
import MedalPromoBackground from "~/assets/images/illustrations/medal_promo_background.svg?component";
// eslint-disable-next-line import/no-named-as-default-member
dayjs.extend(dayjsDuration);
const props = defineProps<Partial<Server>>();
const showGameLabel = computed(() => !!props.game);
const showLoaderLabel = computed(() => !!props.loader);
let projectData: Ref<Project | null>;
if (props.upstream) {
const { data } = await useAsyncData<Project>(
`server-project-${props.server_id}`,
async (): Promise<Project> => {
const result = await useBaseFetch(`project/${props.upstream?.project_id}`);
return result as Project;
},
);
projectData = data;
} else {
projectData = ref(null);
}
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
const isConfiguring = computed(() => props.flows?.intro);
const expiryDate = dayjs().add(5, "day");
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 });
function handleUpgrade(event: Event) {
event.stopPropagation();
// TODO: Upgrade logic.
}
function updateCountdown() {
const now = dayjs();
const diff = expiryDate.diff(now);
if (diff <= 0) {
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 };
return;
}
const duration = dayjs.duration(diff);
timeLeftCountdown.value = {
days: duration.days(),
hours: duration.hours(),
minutes: duration.minutes(),
seconds: duration.seconds(),
};
}
updateCountdown();
const intervalId = ref<NodeJS.Timeout | null>(null);
onMounted(() => {
intervalId.value = setInterval(updateCountdown, 1000);
});
onUnmounted(() => {
if (intervalId.value) clearInterval(intervalId.value);
});
</script>
<style scoped lang="scss">
.medal-promotion {
position: relative;
border: 1px solid var(--medal-promotion-bg-orange);
background: inherit; // allows overlay + pattern to take over
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--medal-promotion-bg-gradient);
z-index: 1;
border-radius: inherit;
}
.background-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background-color: var(--medal-promotion-bg);
border-radius: inherit;
color: var(--medal-promotion-text-orange);
}
.text-medal-orange {
color: var(--medal-promotion-text-orange);
font-weight: bold;
}
.border-medal-orange {
border-color: var(--medal-promotion-bg-orange);
}
</style>

View File

@@ -34,6 +34,38 @@
</div>
</div>
</Modal>
<Modal ref="modalReply" header="Reply to thread">
<div class="modal-submit universal-body">
<span>
Your project is already approved. As such, the moderation team does not actively monitor
this thread. However, they may still see your message if there is a problem with your
project.
</span>
<span>
If you need to get in contact with the moderation team, please use the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</span>
<Checkbox
v-model="replyConfirmation"
description="Confirm moderators do not actively monitor this"
>
I acknowledge that the moderators do not actively monitor the thread.
</Checkbox>
<div class="input-group push-right">
<button
class="btn btn-primary"
:disabled="!replyConfirmation"
@click="sendReplyFromModal()"
>
<ReplyIcon aria-hidden="true" />
Reply to thread
</button>
</div>
</div>
</Modal>
<div v-if="flags.developerMode" class="thread-id">
Thread ID:
<CopyCode :text="thread.id" />
@@ -71,12 +103,17 @@
v-if="sortedMessages.length > 0"
class="btn btn-primary"
:disabled="!replyBody"
@click="sendReply()"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
>
<ReplyIcon aria-hidden="true" />
Reply
</button>
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()">
<button
v-else
class="btn btn-primary"
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
>
<SendIcon aria-hidden="true" />
Send
</button>
@@ -289,6 +326,7 @@ const sortedMessages = computed(() => {
});
const modalSubmit = ref(null);
const modalReply = ref(null);
async function updateThreadLocal() {
let threadId = null;
@@ -316,6 +354,11 @@ async function onUploadImage(file) {
return response.url;
}
async function sendReplyFromModal(status = null, privateMessage = false) {
modalReply.value.hide();
await sendReply(status, privateMessage);
}
async function sendReply(status = null, privateMessage = false) {
try {
const body = {
@@ -398,6 +441,7 @@ async function reopenReport() {
const replyWithSubmission = ref(false);
const submissionConfirmation = ref(false);
const replyConfirmation = ref(false);
function openResubmitModal(reply) {
submissionConfirmation.value = false;
@@ -405,6 +449,11 @@ function openResubmitModal(reply) {
modalSubmit.value.show();
}
function openReplyModal(reply) {
replyConfirmation.value = false;
modalReply.value.show();
}
async function resubmit() {
if (replyWithSubmission.value) {
await sendReply("processing");

View File

@@ -0,0 +1,282 @@
<template>
<div>
<div v-if="flags.developerMode" class="mb-4 font-bold text-heading">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div
v-if="sortedMessages.length > 0"
class="bg-raised flex flex-col space-y-4 rounded-xl p-3 sm:p-4"
>
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:report="report"
:auth="auth"
raised
@update-thread="() => updateThreadLocal()"
/>
</div>
<template v-if="reportClosed">
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2 w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="reopenReport()"
>
<CheckCircleIcon class="size-4" />
Reopen Thread
</button>
</ButtonStyled>
</template>
<template v-else>
<div class="mt-4">
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
:on-image-upload="onUploadImage"
/>
</div>
<div
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<ButtonStyled v-if="sortedMessages.length > 0" color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<ReplyIcon class="size-4" />
Reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<SendIcon class="size-4" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply(true)"
>
<ScaleIcon class="size-4" />
<span class="hidden sm:inline">Add private note</span>
<span class="sm:hidden">Private note</span>
</button>
</ButtonStyled>
</div>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="replyBody" color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport(true)"
>
<CheckCircleIcon class="size-4" />
<span class="hidden sm:inline">Close with reply</span>
<span class="sm:hidden">Close & reply</span>
</button>
</ButtonStyled>
<ButtonStyled v-else color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport()"
>
<CheckCircleIcon class="size-4" />
Close report
</button>
</ButtonStyled>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { CopyCode, MarkdownEditor, ButtonStyled } from "@modrinth/ui";
import { ReplyIcon, SendIcon, CheckCircleIcon, ScaleIcon } from "@modrinth/assets";
import type { Thread, Report, User, ThreadMessage as TypeThreadMessage } from "@modrinth/utils";
import dayjs from "dayjs";
import ThreadMessage from "./ThreadMessage.vue";
import { useImageUpload } from "~/composables/image-upload.ts";
import { isStaff } from "~/helpers/users.js";
const props = defineProps<{
thread: Thread;
reporter: User;
report: Report;
}>();
const auth = await useAuth();
const emit = defineEmits<{
updateThread: [thread: Thread];
}>();
const flags = useFeatureFlags();
const members = computed(() => {
const membersMap: Record<string, User> = {
[props.reporter.id]: props.reporter,
};
for (const member of props.thread.members) {
membersMap[member.id] = member;
}
return membersMap;
});
const replyBody = ref("");
function setReplyContent(content: string) {
replyBody.value = content;
}
defineExpose({
setReplyContent,
});
const sortedMessages = computed(() => {
const messages: TypeThreadMessage[] = [
{
id: null,
author_id: props.reporter.id,
body: {
type: "text",
body: props.report.body || "Report opened.",
private: false,
replying_to: null,
associated_images: [],
},
created: props.report.created,
hide_identity: false,
},
];
if (props.thread) {
messages.push(
...[...props.thread.messages].sort(
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
),
);
}
return messages;
});
async function updateThreadLocal() {
const threadId = props.report.thread_id;
if (threadId) {
try {
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread;
emit("updateThread", thread);
} catch (error) {
console.error("Failed to update thread:", error);
}
}
}
const imageIDs = ref<string[]>([]);
async function onUploadImage(file: File) {
const response = await useImageUpload(file, { context: "thread_message" });
imageIDs.value.push(response.id);
imageIDs.value = imageIDs.value.slice(-10);
return response.url;
}
async function sendReply(privateMessage = false) {
try {
const body: any = {
body: {
type: "text",
body: replyBody.value,
private: privateMessage,
},
};
if (imageIDs.value.length > 0) {
body.body = {
...body.body,
uploaded_images: imageIDs.value,
};
}
await useBaseFetch(`thread/${props.thread.id}`, {
method: "POST",
body,
});
replyBody.value = "";
await updateThreadLocal();
} catch (err: any) {
addNotification({
title: "Error sending message",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
const didCloseReport = ref(false);
const reportClosed = computed(() => {
return didCloseReport.value || (props.report && props.report.closed);
});
async function closeReport(reply = false) {
if (reply) {
await sendReply();
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: "PATCH",
body: {
closed: true,
},
});
await updateThreadLocal();
didCloseReport.value = true;
} catch (err: any) {
addNotification({
title: "Error closing report",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
async function reopenReport() {
try {
await useBaseFetch(`report/${props.report.id}`, {
method: "PATCH",
body: {
closed: false,
},
});
await updateThreadLocal();
} catch (err: any) {
addNotification({
title: "Error reopening report",
text: err.data ? err.data.description : err,
type: "error",
});
}
}
</script>

View File

@@ -36,7 +36,7 @@
v-tooltip="'Modrinth Team'"
/>
<MicrophoneIcon
v-if="report && message.author_id === report.reporterUser.id"
v-if="report && message.author_id === report.reporter_user?.id"
v-tooltip="'Reporter'"
class="reporter-icon"
/>

View File

@@ -25,6 +25,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
// Feature toggles
projectTypesPrimaryNav: false,
enableMedalPromotion: true,
hidePlusPromoInUserMenu: false,
oldProjectCards: true,
newProjectCards: false,

View File

@@ -102,7 +102,7 @@ export class ModrinthServer {
try {
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
override: auth,
retry: false,
retry: 1, // Reduce retries for optional resources
});
if (fileData instanceof Blob && import.meta.client) {
@@ -124,8 +124,14 @@ export class ModrinthServer {
return dataURL;
}
} catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 404) {
if (iconUrl) {
if (error instanceof ModrinthServerError) {
if (error.statusCode && error.statusCode >= 500) {
console.debug("Service unavailable, skipping icon processing");
sharedImage.value = undefined;
return undefined;
}
if (error.statusCode === 404 && iconUrl) {
try {
const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
@@ -187,6 +193,44 @@ export class ModrinthServer {
return undefined;
}
async testNodeReachability(): Promise<boolean> {
if (!this.general?.node?.instance) {
console.warn("No node instance available for ping test");
return false;
}
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
try {
return await new Promise((resolve) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
resolve(false);
}, 5000);
socket.onopen = () => {
clearTimeout(timeout);
socket.send(performance.now().toString());
};
socket.onmessage = () => {
clearTimeout(timeout);
socket.close();
resolve(true);
};
socket.onerror = () => {
clearTimeout(timeout);
resolve(false);
};
});
} catch (error) {
console.error(`Failed to ping node ${wsUrl}:`, error);
return false;
}
}
async refresh(
modules: ModuleName[] = [],
options?: {
@@ -200,6 +244,8 @@ export class ModrinthServer {
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
for (const module of modulesToRefresh) {
this.errors[module] = undefined;
try {
switch (module) {
case "general": {
@@ -250,7 +296,7 @@ export class ModrinthServer {
continue;
}
if (error.statusCode === 503) {
if (error.statusCode && error.statusCode >= 500) {
console.debug(`Temporary ${module} unavailable:`, error.message);
continue;
}

View File

@@ -22,26 +22,49 @@ export class FSModule extends ServerModule {
this.opsQueuedForModification = [];
}
private async retryWithAuth<T>(requestFn: () => Promise<T>): Promise<T> {
private async retryWithAuth<T>(
requestFn: () => Promise<T>,
ignoreFailure: boolean = false,
): Promise<T> {
try {
return await requestFn();
} catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 401) {
console.debug("Auth failed, refreshing JWT and retrying");
await this.fetch(); // Refresh auth
return await requestFn();
}
const available = await this.server.testNodeReachability();
if (!available && !ignoreFailure) {
this.server.moduleErrors.general = {
error: new ModrinthServerError(
"Unable to reach node. FS operation failed and subsequent ping test failed.",
500,
error as Error,
"fs",
),
timestamp: Date.now(),
};
}
throw error;
}
}
listDirContents(path: string, page: number, pageSize: number): Promise<DirectoryResponse> {
listDirContents(
path: string,
page: number,
pageSize: number,
ignoreFailure: boolean = false,
): Promise<DirectoryResponse> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path);
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
override: this.auth,
retry: false,
});
});
}, ignoreFailure);
}
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
@@ -150,7 +173,7 @@ export class FSModule extends ServerModule {
});
}
downloadFile(path: string, raw?: boolean): Promise<any> {
downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path);
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
@@ -161,7 +184,7 @@ export class FSModule extends ServerModule {
return raw ? fileData : await fileData.text();
}
return fileData;
});
}, ignoreFailure);
}
extractFile(

View File

@@ -46,13 +46,18 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
}
const motd = await this.getMotd();
if (motd === "A Minecraft Server") {
await this.setMotd(
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
);
try {
const motd = await this.getMotd();
if (motd === "A Minecraft Server") {
await this.setMotd(
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
);
}
data.motd = motd;
} catch {
console.error("[Modrinth Servers] [General] Failed to fetch MOTD.");
data.motd = undefined;
}
data.motd = motd;
// Copy data to this module
Object.assign(this, data);
@@ -178,7 +183,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
async getMotd(): Promise<string | undefined> {
try {
const props = await this.server.fs.downloadFile("/server.properties");
const props = await this.server.fs.downloadFile("/server.properties", false, true);
if (props) {
const lines = props.split("\n");
for (const line of lines) {

View File

@@ -42,6 +42,23 @@ export async function useServersFetch<T>(
retry = method === "GET" ? 3 : 0,
} = options;
const circuitBreakerKey = `${module || "default"}_${path}`;
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0);
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0);
const now = Date.now();
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
const error = new ModrinthServersFetchError(
"[Modrinth Servers] Circuit breaker open - too many recent failures",
503,
);
throw new ModrinthServerError("Service temporarily unavailable", 503, error, module);
}
if (now - lastFailureTime.value > 30000) {
failureCount.value = 0;
}
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
"",
@@ -69,6 +86,7 @@ export async function useServersFetch<T>(
const headers: Record<string, string> = {
"User-Agent": "Modrinth/1.0 (https://modrinth.com)",
"X-Archon-Request": "true",
Vary: "Accept, Origin",
};
@@ -94,10 +112,12 @@ export async function useServersFetch<T>(
const response = await $fetch<T>(fullUrl, {
method,
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
body:
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000,
});
failureCount.value = 0;
return response;
} catch (error) {
lastError = error as Error;
@@ -107,6 +127,11 @@ export async function useServersFetch<T>(
const statusCode = error.response?.status;
const statusText = error.response?.statusText || "Unknown error";
if (statusCode && statusCode >= 500) {
failureCount.value++;
lastFailureTime.value = now;
}
let v1Error: V1ErrorInfo | undefined;
if (error.data?.error && error.data?.description) {
v1Error = {
@@ -122,7 +147,7 @@ export async function useServersFetch<T>(
404: "Not Found",
405: "Method Not Allowed",
408: "Request Timeout",
429: "Too Many Requests",
429: "You're making requests too quickly. Please wait a moment and try again.",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
@@ -134,20 +159,29 @@ export async function useServersFetch<T>(
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true;
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false;
const is5xxRetryable =
statusCode && statusCode >= 500 && statusCode < 600 && method === "GET" && attempts === 1;
if (!isRetryable || attempts >= maxAttempts) {
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
console.error("Fetch error:", error);
const fetchError = new ModrinthServersFetchError(
`[Modrinth Servers] ${message}`,
`[Modrinth Servers] ${error.message}`,
statusCode,
error,
);
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
throw new ModrinthServerError(
`[Modrinth Servers] ${message}`,
statusCode,
fetchError,
module,
v1Error,
);
}
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000);
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;

View File

@@ -0,0 +1,12 @@
export const useNotificationRightwards = () => {
const isVisible = useState("moderation-checklist-notifications", () => false);
const setVisible = (visible: boolean) => {
isVisible.value = visible;
};
return {
isVisible: readonly(isVisible),
setVisible,
};
};

View File

@@ -0,0 +1,236 @@
import type { ExtendedReport, OwnershipTarget } from "@modrinth/moderation";
import type {
Thread,
Version,
User,
Project,
TeamMember,
Organization,
Report,
} from "@modrinth/utils";
export const useModerationCache = () => ({
threads: useState<Map<string, Thread>>("moderation-report-cache-threads", () => new Map()),
users: useState<Map<string, User>>("moderation-report-cache-users", () => new Map()),
projects: useState<Map<string, Project>>("moderation-report-cache-projects", () => new Map()),
versions: useState<Map<string, Version>>("moderation-report-cache-versions", () => new Map()),
teams: useState<Map<string, TeamMember[]>>("moderation-report-cache-teams", () => new Map()),
orgs: useState<Map<string, Organization>>("moderation-report-cache-orgs", () => new Map()),
});
// TODO: @AlexTMjugador - backend should do all of these functions.
export async function enrichReportBatch(reports: Report[]): Promise<ExtendedReport[]> {
if (reports.length === 0) return [];
const cache = useModerationCache();
const threadIDs = reports
.map((r) => r.thread_id)
.filter(Boolean)
.filter((id) => !cache.threads.value.has(id));
const userIDs = [
...reports.filter((r) => r.item_type === "user").map((r) => r.item_id),
...reports.map((r) => r.reporter),
].filter((id) => !cache.users.value.has(id));
const versionIDs = reports
.filter((r) => r.item_type === "version")
.map((r) => r.item_id)
.filter((id) => !cache.versions.value.has(id));
const projectIDs = reports
.filter((r) => r.item_type === "project")
.map((r) => r.item_id)
.filter((id) => !cache.projects.value.has(id));
const [newThreads, newVersions, newUsers] = await Promise.all([
threadIDs.length > 0
? (fetchSegmented(threadIDs, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`) as Promise<
Thread[]
>)
: Promise.resolve([]),
versionIDs.length > 0
? (fetchSegmented(versionIDs, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`) as Promise<
Version[]
>)
: Promise.resolve([]),
[...new Set(userIDs)].length > 0
? (fetchSegmented(
[...new Set(userIDs)],
(ids) => `users?ids=${asEncodedJsonArray(ids)}`,
) as Promise<User[]>)
: Promise.resolve([]),
]);
newThreads.forEach((t) => cache.threads.value.set(t.id, t));
newVersions.forEach((v) => cache.versions.value.set(v.id, v));
newUsers.forEach((u) => cache.users.value.set(u.id, u));
const allVersions = [...newVersions, ...Array.from(cache.versions.value.values())];
const fullProjectIds = new Set([
...projectIDs,
...allVersions
.filter((v) => versionIDs.includes(v.id))
.map((v) => v.project_id)
.filter(Boolean),
]);
const uncachedProjectIds = Array.from(fullProjectIds).filter(
(id) => !cache.projects.value.has(id),
);
const newProjects =
uncachedProjectIds.length > 0
? ((await fetchSegmented(
uncachedProjectIds,
(ids) => `projects?ids=${asEncodedJsonArray(ids)}`,
)) as Project[])
: [];
newProjects.forEach((p) => cache.projects.value.set(p.id, p));
const allProjects = [...newProjects, ...Array.from(cache.projects.value.values())];
const teamIds = [...new Set(allProjects.map((p) => p.team).filter(Boolean))].filter(
(id) => !cache.teams.value.has(id || "invalid team id"),
);
const orgIds = [...new Set(allProjects.map((p) => p.organization).filter(Boolean))].filter(
(id) => !cache.orgs.value.has(id),
);
const [newTeams, newOrgs] = await Promise.all([
teamIds.length > 0
? (fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`) as Promise<
TeamMember[][]
>)
: Promise.resolve([]),
orgIds.length > 0
? (fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
}) as Promise<Organization[]>)
: Promise.resolve([]),
]);
newTeams.forEach((team) => {
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
});
newOrgs.forEach((org) => cache.orgs.value.set(org.id, org));
return reports.map((report) => {
const thread = cache.threads.value.get(report.thread_id) || ({} as Thread);
const version =
report.item_type === "version" ? cache.versions.value.get(report.item_id) : undefined;
const project =
report.item_type === "project"
? cache.projects.value.get(report.item_id)
: report.item_type === "version" && version
? cache.projects.value.get(version.project_id)
: undefined;
let target: OwnershipTarget | undefined;
if (report.item_type === "user") {
const targetUser = cache.users.value.get(report.item_id);
if (targetUser) {
target = {
name: targetUser.username,
slug: targetUser.username,
avatar_url: targetUser.avatar_url,
type: "user",
};
}
} else if (project) {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project.team) {
const teamMembers = cache.teams.value.get(project.team);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.organization) {
org = cache.orgs.value.get(project.organization) || null;
}
if (org) {
target = {
name: org.name,
avatar_url: org.icon_url,
type: "organization",
slug: org.slug,
};
} else if (owner) {
target = {
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: "user",
slug: owner.user.username,
};
}
}
return {
...report,
thread,
reporter_user: cache.users.value.get(report.reporter) || ({} as User),
project,
user: report.item_type === "user" ? cache.users.value.get(report.item_id) : undefined,
version,
target,
};
});
}
// Doesn't need to be in @modrinth/moderation because it is specific to the frontend.
export interface ModerationProject {
project: any;
owner: TeamMember | null;
org: Organization | null;
}
export async function enrichProjectBatch(projects: any[]): Promise<ModerationProject[]> {
const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))];
const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))];
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
]);
const cache = useModerationCache();
teamsData.forEach((team) => {
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
});
orgsData.forEach((org: Organization) => {
cache.orgs.value.set(org.id, org);
});
return projects.map((project) => {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project.team_id) {
const teamMembers = cache.teams.value.get(project.team_id);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.organization) {
org = cache.orgs.value.get(project.organization) || null;
}
return {
project,
owner,
org,
} as ModerationProject;
});
}

View File

@@ -295,7 +295,7 @@
{
id: 'review-projects',
color: 'orange',
link: '/moderation/review',
link: '/moderation/',
},
{
id: 'review-reports',
@@ -700,7 +700,6 @@ import {
PackageOpenIcon,
DiscordIcon,
BlueskyIcon,
TumblrIcon,
TwitterIcon,
MastodonIcon,
GithubIcon,
@@ -982,23 +981,6 @@ const userMenuOptions = computed(() => {
},
];
if (
(auth.value && auth.value.user && auth.value.user.role === "moderator") ||
auth.value.user.role === "admin"
) {
options = [
...options,
{
divider: true,
},
{
id: "moderation",
color: "orange",
link: "/moderation/review",
},
];
}
options = [
...options,
{
@@ -1185,13 +1167,6 @@ const socialLinks = [
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",
@@ -1346,6 +1321,15 @@ const footerLinks = [
}),
),
},
{
href: "/legal/copyright",
label: formatMessage(
defineMessage({
id: "layout.footer.legal.copyright-policy",
defaultMessage: "Copyright Policy and DMCA",
}),
),
},
],
},
];

View File

@@ -182,9 +182,6 @@
"collection.button.unfollow-project": {
"message": "Unfollow project"
},
"collection.button.upload-icon": {
"message": "Upload icon"
},
"collection.delete-modal.description": {
"message": "This will remove this collection forever. This action cannot be undone."
},
@@ -383,15 +380,15 @@
"layout.footer.about": {
"message": "About"
},
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.careers": {
"message": "Careers"
},
"layout.footer.about.changelog": {
"message": "Changelog"
},
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.rewards-program": {
"message": "Rewards Program"
},
@@ -404,6 +401,9 @@
"layout.footer.legal-disclaimer": {
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
},
"layout.footer.legal.copyright-policy": {
"message": "Copyright Policy and DMCA"
},
"layout.footer.legal.privacy-policy": {
"message": "Privacy Policy"
},
@@ -458,9 +458,6 @@
"layout.footer.social.mastodon": {
"message": "Mastodon"
},
"layout.footer.social.tumblr": {
"message": "Tumblr"
},
"layout.footer.social.x": {
"message": "X"
},
@@ -479,6 +476,30 @@
"layout.nav.search": {
"message": "Search"
},
"moderation.filter.by": {
"message": "Filter by"
},
"moderation.moderate": {
"message": "Moderate"
},
"moderation.page.projects": {
"message": "Projects"
},
"moderation.page.reports": {
"message": "Reports"
},
"moderation.page.technicalReview": {
"message": "Technical Review"
},
"moderation.search.placeholder": {
"message": "Search..."
},
"moderation.sort.by": {
"message": "Sort by"
},
"moderation.technical.search.placeholder": {
"message": "Search tech reviews..."
},
"profile.button.billing": {
"message": "Manage user billing"
},

View File

@@ -29,12 +29,11 @@
class="settings-header__icon"
/>
<div class="settings-header__text">
<h1 class="wrap-as-needed">
{{ project.title }}
</h1>
<h1 class="wrap-as-needed">{{ project.title }}</h1>
<ProjectStatusBadge :status="project.status" />
</div>
</div>
<h2>Project settings</h2>
<NavStack>
<NavStackItem
@@ -111,6 +110,7 @@
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<ProjectMemberHeader
v-if="currentMember"
@@ -145,6 +145,7 @@
/>
</div>
</div>
<div v-else class="experimental-styles-within">
<NewModal ref="settingsModal">
<template #title>
@@ -174,9 +175,11 @@
<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"
>
@@ -219,8 +222,7 @@
:href="`modrinth://mod/${project.slug}`"
@click="() => installWithApp()"
>
<ModrinthIcon aria-hidden="true" />
Install with Modrinth App
<ModrinthIcon aria-hidden="true" /> Install with Modrinth App
<ExternalIcon aria-hidden="true" />
</a>
</ButtonStyled>
@@ -240,6 +242,7 @@
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
</div>
</div>
<div class="mx-auto flex w-fit flex-col gap-2">
<ButtonStyled v-if="project.game_versions.length === 1">
<div class="disabled button-like">
@@ -327,8 +330,7 @@
}
"
>
{{ gameVersion }}
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
{{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" />
</button>
</ButtonStyled>
</ScrollablePanel>
@@ -419,7 +421,6 @@
</ScrollablePanel>
</Accordion>
</div>
<AutomaticAccordion div class="flex flex-col gap-2">
<VersionSummary
v-if="filteredRelease"
@@ -470,10 +471,14 @@
class="new-page sidebar"
:class="{
'alt-layout': cosmetics.leftContentLayout,
'ultimate-sidebar':
'checklist-open':
showModerationChecklist &&
!collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup,
'checklist-collapsed':
showModerationChecklist &&
collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup,
}"
>
<div class="normal-page__header relative my-4">
@@ -485,11 +490,11 @@
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
>
<button @click="(event) => downloadModal.show(event)">
<DownloadIcon aria-hidden="true" />
Download
<DownloadIcon aria-hidden="true" /> Download
</button>
</ButtonStyled>
</div>
<div class="contents sm:hidden">
<ButtonStyled
size="large"
@@ -554,9 +559,11 @@
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
Modrinth Servers is the easiest way to play with your friends without hassle!
</p>
<p class="m-0 text-wrap text-sm font-bold text-primary">
Starting at $5<span class="text-xs"> / month</span>
</p>
@@ -621,6 +628,7 @@
{{ option.name }}
</Checkbox>
</div>
<div v-else class="menu-text">
<p class="popout-text">No collections found.</p>
</div>
@@ -628,8 +636,7 @@
class="btn collection-button"
@click="(event) => $refs.modal_collection.show(event)"
>
<PlusIcon aria-hidden="true" />
Create new collection
<PlusIcon aria-hidden="true" /> Create new collection
</button>
</template>
</PopoutMenu>
@@ -682,7 +689,10 @@
},
{
id: 'moderation-checklist',
action: () => (showModerationChecklist = true),
action: () => {
moderationStore.setSingleProject(project.id);
showModerationChecklist = true;
},
color: 'orange',
hoverOnly: true,
shown:
@@ -712,25 +722,14 @@
:dropdown-id="`${baseId}-more-options`"
>
<MoreVerticalIcon aria-hidden="true" />
<template #analytics>
<ChartIcon aria-hidden="true" />
Analytics
</template>
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
<template #moderation-checklist>
<ScaleIcon aria-hidden="true" />
Review project
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
<ScaleIcon aria-hidden="true" /> Review project
</template>
<template #report> <ReportIcon aria-hidden="true" /> Report </template>
<template #copy-id> <ClipboardCopyIcon aria-hidden="true" /> Copy ID </template>
<template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" />
Copy permanent link
<ClipboardCopyIcon aria-hidden="true" /> Copy permanent link
</template>
</OverflowMenu>
</ButtonStyled>
@@ -756,6 +755,7 @@
updates unless the author decides to unarchive the project.
</MessageBanner>
</div>
<div class="normal-page__sidebar">
<ProjectSidebarCompatibility
:project="project"
@@ -785,6 +785,7 @@
/>
<div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div class="details-list__item">
<BookTextIcon aria-hidden="true" />
@@ -813,53 +814,48 @@
<span v-else>{{ licenseIdDisplay }}</span>
</div>
</div>
<div
v-if="project.approved"
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
</div>
<div>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div>
</div>
<div
v-else
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
</div>
<div>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div>
</div>
<div
v-if="project.status === 'processing' && project.queued"
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<ScaleIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
</div>
<div>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div>
</div>
<div
v-if="versions.length > 0 && project.updated"
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<VersionIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
</div>
<div>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div>
</div>
</div>
</div>
</div>
<div class="normal-page__content">
<div class="overflow-x-auto">
<NavTabs :links="navLinks" class="mb-4" />
</div>
<div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div>
<NuxtPage
v-model:project="project"
v-model:versions="versions"
@@ -877,20 +873,22 @@
@delete-version="deleteVersion"
/>
</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>
<div
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
class="moderation-checklist"
>
<ModerationChecklist
:project="project"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
</template>
<script setup>
import {
BookmarkIcon,
@@ -942,24 +940,17 @@ import {
useRelativeTime,
} from "@modrinth/ui";
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
import {
formatCategory,
formatProjectType,
isRejected,
isStaff,
isUnderReview,
renderString,
} from "@modrinth/utils";
import { navigateTo } from "#app";
import { formatCategory, formatProjectType, renderString } from "@modrinth/utils";
import dayjs from "dayjs";
import { Tooltip } from "floating-vue";
import { useLocalStorage } from "@vueuse/core";
import { navigateTo } from "#app";
import Accordion from "~/components/ui/Accordion.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import MessageBanner from "~/components/ui/MessageBanner.vue";
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue";
import NavTabs from "~/components/ui/NavTabs.vue";
@@ -967,10 +958,13 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
import { userCollectProject } from "~/composables/user.js";
import { reportProject } from "~/utils/report-helpers.ts";
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
import ModerationChecklist from "~/components/ui/moderation/checklist/ModerationChecklist.vue";
import { useModerationStore } from "~/store/moderation.ts";
const data = useNuxtApp();
const route = useNativeRoute();
const config = useRuntimeConfig();
const moderationStore = useModerationStore();
const auth = await useAuth();
const user = await useUser();
@@ -980,6 +974,7 @@ const flags = useFeatureFlags();
const cosmetics = useCosmetics();
const { formatMessage } = useVIntl();
const { setVisible } = useNotificationRightwards();
const settingsModal = ref();
const downloadModal = ref();
@@ -1551,12 +1546,22 @@ async function copyPermalink() {
const collapsedChecklist = ref(false);
const showModerationChecklist = ref(false);
const collapsedModerationChecklist = ref(false);
const futureProjects = ref([]);
const showModerationChecklist = useLocalStorage(
`show-moderation-checklist-${project.value.id}`,
false,
);
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
watch(
showModerationChecklist,
(newValue) => {
setVisible(newValue);
},
{ immediate: true },
);
if (import.meta.client && history && history.state && history.state.showChecklist) {
showModerationChecklist.value = true;
futureProjects.value = history.state.projects;
}
function closeDownloadModal(event) {
@@ -1619,13 +1624,12 @@ const navLinks = computed(() => {
{
label: formatMessage(messages.moderationTab),
href: `${projectUrl}/moderation`,
shown:
!!currentMember.value &&
(isRejected(project.value) || isUnderReview(project.value) || isStaff(auth.value.user)),
shown: !!currentMember.value,
},
];
});
</script>
<style lang="scss" scoped>
.settings-header {
display: flex;
@@ -1781,4 +1785,16 @@ const navLinks = computed(() => {
left: 18px;
}
}
.moderation-checklist {
position: fixed;
bottom: 1rem;
right: 1rem;
overflow-y: auto;
z-index: 50;
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
</style>

View File

@@ -365,8 +365,10 @@ export default defineNuxtComponent({
if (e.key === "Escape") {
this.expandedGalleryItem = null;
} else if (e.key === "ArrowLeft") {
e.stopPropagation();
this.previousImage();
} else if (e.key === "ArrowRight") {
e.stopPropagation();
this.nextImage();
}
}
@@ -705,9 +707,9 @@ export default defineNuxtComponent({
}
.gallery-body {
flex-grow: 1;
width: calc(100% - 2 * var(--spacing-card-md));
padding: var(--spacing-card-sm) var(--spacing-card-md);
overflow-wrap: anywhere;
.gallery-info {
h2 {

View File

@@ -76,8 +76,15 @@
<p>
This is a private conversation thread with the Modrinth moderators. They may message you
with issues concerning this project. This thread is only checked when you submit your
project for review. For additional inquiries, contact
<a href="https://support.modrinth.com">Modrinth Support</a>.
project for review. For additional inquiries, please go to the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</p>
<p v-if="isApproved(project)" class="warning">
<IssuesIcon /> The moderators do not actively monitor this chat. However, they may still see
messages here if there is a problem with your project.
</p>
<ConversationThread
v-if="thread"

View File

@@ -58,6 +58,41 @@
</div>
</div>
</NewModal>
<NewModal ref="modifyModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Modify charge</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="cancel" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Cancel server
<span class="text-brand-red">*</span>
</span>
<span>
Whether or not the subscription should be cancelled. Submitting this as "true" will
cancel the subscription, while submitting it as "false" will force another charge
attempt to be made.
</span>
</label>
<Toggle id="cancel" v-model="cancel" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="modifying" @click="modifyCharge">
<CheckIcon aria-hidden="true" />
Modify charge
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modifyModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="page experimental-styles-within">
<div
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
@@ -150,9 +185,26 @@
</template>
</span>
<span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary"
>({{ formatRelativeTime(charge.last_attempt) }})
</span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}
@@ -184,6 +236,12 @@
Refund options
</button>
</ButtonStyled>
<ButtonStyled v-else-if="charge.status === 'failed'" color="red" color-fill="text">
<button @click="showModifyModal(subscription)">
<CurrencyIcon />
Modify charge
</button>
</ButtonStyled>
</div>
</div>
</div>
@@ -217,7 +275,6 @@ import { products } from "~/generated/state.json";
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
const route = useRoute();
const data = useNuxtApp();
const vintl = useVIntl();
const { formatMessage } = vintl;
@@ -287,6 +344,10 @@ const refundTypes = ref(["full", "partial", "none"]);
const refundAmount = ref(0);
const unprovision = ref(true);
const modifying = ref(false);
const modifyModal = ref();
const cancel = ref(false);
function showRefundModal(charge) {
selectedCharge.value = charge;
refundType.value = "full";
@@ -295,6 +356,12 @@ function showRefundModal(charge) {
refundModal.value.show();
}
function showModifyModal(charge) {
selectedCharge.value = charge;
cancel.value = false;
modifyModal.value.show();
}
async function refundCharge() {
refunding.value = true;
try {
@@ -310,8 +377,7 @@ async function refundCharge() {
await refreshCharges();
refundModal.value.hide();
} catch (err) {
data.$notify({
group: "main",
addNotification({
title: "Error refunding",
text: err.data?.description ?? err,
type: "error",
@@ -320,6 +386,32 @@ async function refundCharge() {
refunding.value = false;
}
async function modifyCharge() {
modifying.value = true;
try {
await useBaseFetch(`billing/subscription/${selectedCharge.value.id}`, {
method: "PATCH",
body: JSON.stringify({
cancelled: cancel.value,
}),
internal: true,
});
addNotification({
title: "Resubscription request submitted",
text: "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.",
type: "success",
});
await refreshCharges();
} catch (err) {
addNotification({
title: "Error reattempting charge",
text: err.data?.description ?? err,
type: "error",
});
}
modifying.value = false;
}
const chargeStatuses = {
open: {
color: "bg-blue",

View File

@@ -58,7 +58,7 @@ const rows = [
];
const { data: launcherUpdates } = await useFetch<LauncherUpdates>(
"https://launcher-files.modrinth.com/updates.json",
"https://launcher-files.modrinth.com/updates.json?new",
{
server: false,
getCachedData(key, nuxtApp) {
@@ -119,15 +119,24 @@ const downloadLauncher = computed(() => {
}
});
onBeforeMount(() => {
if (launcherUpdates.value?.platforms) {
macLinks.universal = launcherUpdates.value.platforms["darwin-aarch64"]?.install_urls[0] || null;
windowsLink.value = launcherUpdates.value.platforms["windows-x86_64"]?.install_urls[0] || null;
linuxLinks.appImage = launcherUpdates.value.platforms["linux-x86_64"]?.install_urls[1] || null;
linuxLinks.deb = launcherUpdates.value.platforms["linux-x86_64"]?.install_urls[0] || null;
linuxLinks.rpm = launcherUpdates.value.platforms["linux-x86_64"]?.install_urls[2] || null;
}
});
const handleDownload = () => {
downloadLauncher.value();
};
watch(
launcherUpdates,
(newData) => {
if (newData?.platforms) {
macLinks.universal = newData.platforms["darwin-aarch64"]?.install_urls[0] || null;
windowsLink.value = newData.platforms["windows-x86_64"]?.install_urls[0] || null;
linuxLinks.appImage = newData.platforms["linux-x86_64"]?.install_urls[1] || null;
linuxLinks.deb = newData.platforms["linux-x86_64"]?.install_urls[0] || null;
linuxLinks.rpm = newData.platforms["linux-x86_64"]?.install_urls[2] || null;
}
},
{ immediate: true },
);
const scrollToSection = () => {
nextTick(() => {
if (downloadSection.value) {
@@ -168,7 +177,7 @@ useSeoMeta({
v-if="os"
class="iconified-button brand-button btn btn-large"
rel="noopener nofollow"
@click="downloadLauncher"
@click="handleDownload"
>
<svg
v-if="os === 'Linux'"
@@ -1412,7 +1421,8 @@ useSeoMeta({
width: 25rem;
height: 25rem;
opacity: 0.75;
background: radial-gradient(
background:
radial-gradient(
50% 50% at 50% 50%,
rgba(5, 206, 69, 0.19) 0%,
rgba(15, 19, 49, 0.25) 100%

View File

@@ -1,6 +1,12 @@
<template>
<div>
<template v-if="flow">
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
@@ -189,6 +195,7 @@ const auth = await useAuth();
const route = useNativeRoute();
const redirectTarget = route.query.redirect || "";
const subtleLauncherRedirectUri = ref();
if (route.query.code && !route.fullPath.includes("new_account=true")) {
await finishSignIn();
@@ -262,7 +269,32 @@ async function begin2FASignIn() {
async function finishSignIn(token) {
if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true });
if (!token) {
token = auth.value.token;
}
const usesLocalhostRedirectionScheme =
["4", "6"].includes(route.query.ipver) && Number(route.query.port) < 65536;
const redirectUrl = usesLocalhostRedirectionScheme
? `http://${route.query.ipver === "4" ? "127.0.0.1" : "[::1]"}:${route.query.port}/?code=${token}`
: `https://launcher-files.modrinth.com/?code=${token}`;
if (usesLocalhostRedirectionScheme) {
// When using this redirection scheme, the auth token is very visible in the URL to the user.
// While we could make it harder to find with a POST request, such is security by obscurity:
// the user and other applications would still be able to sniff the token in the request body.
// So, to make the UX a little better by not changing the displayed URL, while keeping the
// token hidden from very casual observation and keeping the protocol as close to OAuth's
// standard flows as possible, let's execute the redirect within an iframe that visually
// covers the entire page.
subtleLauncherRedirectUri.value = redirectUrl;
} else {
await navigateTo(redirectUrl, {
external: true,
});
}
return;
}

View File

@@ -218,7 +218,7 @@ const username = ref("");
const password = ref("");
const confirmPassword = ref("");
const token = ref("");
const subscribe = ref(true);
const subscribe = ref(false);
async function createAccount() {
startLoading();
@@ -247,16 +247,14 @@ async function createAccount() {
},
});
if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
external: true,
});
return;
}
await useAuth(res.session);
await useUser();
if (route.query.launcher) {
await navigateTo({ path: "/auth/sign-in", query: route.query });
return;
}
if (route.query.redirect) {
await navigateTo(route.query.redirect);
} else {

View File

@@ -40,7 +40,6 @@
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
{{ formatMessage(messages.uploadIconButton) }}
</FileInput>
<Button
v-if="!deletedIcon && (previewImage || collection.icon_url)"
@@ -479,10 +478,6 @@ const messages = defineMessages({
id: "collection.label.updated-at",
defaultMessage: "Updated {ago}",
},
uploadIconButton: {
id: "collection.button.upload-icon",
defaultMessage: "Upload icon",
},
});
const data = useNuxtApp();

View File

@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0;
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0);
});
const minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value;
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value);
});
const withdrawAccount = computed(() => {

View File

@@ -1,33 +1,84 @@
<template>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Moderation</h1>
<NavStack>
<NavStackItem link="/moderation" label="Overview">
<ModrinthIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/moderation/review" label="Review projects">
<ScaleIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem link="/moderation/reports" label="Reports">
<ReportIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtPage />
<div
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<h1>Moderation</h1>
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
<div class="mb-4 sm:hidden">
<Chips
v-model="selectedChip"
:items="mobileNavOptions"
:never-empty="true"
@change="navigateToPage"
/>
</div>
<NuxtPage />
</div>
</template>
<script setup>
import { ModrinthIcon, ScaleIcon, ReportIcon } from "@modrinth/assets";
import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue";
<script setup lang="ts">
import { defineMessages, useVIntl } from "@vintl/vintl";
import { Chips } from "@modrinth/ui";
import NavTabs from "@/components/ui/NavTabs.vue";
definePageMeta({
middleware: "auth",
});
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const messages = defineMessages({
projectsTitle: {
id: "moderation.page.projects",
defaultMessage: "Projects",
},
technicalReviewTitle: {
id: "moderation.page.technicalReview",
defaultMessage: "Technical Review",
},
reportsTitle: {
id: "moderation.page.reports",
defaultMessage: "Reports",
},
});
const moderationLinks = [
{ label: formatMessage(messages.projectsTitle), href: "/moderation" },
{ label: formatMessage(messages.technicalReviewTitle), href: "/moderation/technical-review" },
{ label: formatMessage(messages.reportsTitle), href: "/moderation/reports" },
];
const mobileNavOptions = [
formatMessage(messages.projectsTitle),
formatMessage(messages.technicalReviewTitle),
formatMessage(messages.reportsTitle),
];
const selectedChip = computed({
get() {
const path = route.path;
if (path === "/moderation/technical-review") {
return formatMessage(messages.technicalReviewTitle);
} else if (path.startsWith("/moderation/reports/")) {
return formatMessage(messages.reportsTitle);
} else {
return formatMessage(messages.projectsTitle);
}
},
set(value: string) {
navigateToPage(value);
},
});
function navigateToPage(selectedOption: string) {
if (selectedOption === formatMessage(messages.technicalReviewTitle)) {
router.push("/moderation/technical-review");
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
router.push("/moderation/reports");
} else {
router.push("/moderation");
}
}
</script>

View File

@@ -1,42 +1,339 @@
<template>
<div>
<section class="universal-card">
<h2>Statistics</h2>
<div class="grid-display">
<div class="grid-display__item">
<div class="label">Projects</div>
<div class="value">
{{ formatNumber(stats.projects, false) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">Versions</div>
<div class="value">
{{ formatNumber(stats.versions, false) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">Files</div>
<div class="value">
{{ formatNumber(stats.files, false) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">Authors</div>
<div class="value">
{{ formatNumber(stats.authors, false) }}
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
</section>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
<ButtonStyled color="orange" class="w-full sm:w-auto">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
@click="moderateAllInFilter()"
>
<ScaleIcon class="size-4 flex-shrink-0" />
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
<span class="sm:hidden">Moderate</span>
</button>
</ButtonStyled>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
<ModerationQueueCard
v-for="item in paginatedProjects"
v-else
:key="item.project.id"
:queue-entry="item"
:owner="item.owner"
:org="item.org"
/>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup>
import { formatNumber } from "@modrinth/utils";
<script setup lang="ts">
import { DropdownSelect, Button, ButtonStyled, Pagination } from "@modrinth/ui";
import {
XIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
FilterIcon,
ScaleIcon,
} from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import ConfettiExplosion from "vue-confetti-explosion";
import Fuse from "fuse.js";
import ModerationQueueCard from "~/components/ui/moderation/ModerationQueueCard.vue";
import { useModerationStore } from "~/store/moderation.ts";
import { enrichProjectBatch, type ModerationProject } from "~/helpers/moderation.ts";
useHead({
title: "Staff overview - Modrinth",
const { formatMessage } = useVIntl();
const moderationStore = useModerationStore();
const route = useRoute();
const router = useRouter();
const visible = ref(false);
if (import.meta.client && history && history.state && history.state.confetti) {
setTimeout(async () => {
history.state.confetti = false;
visible.value = true;
await nextTick();
setTimeout(() => {
visible.value = false;
}, 5000);
}, 1000);
}
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.search.placeholder",
defaultMessage: "Search...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
moderate: {
id: "moderation.moderate",
defaultMessage: "Moderate",
},
});
const { data: stats } = await useAsyncData("statistics", () => useBaseFetch("statistics"));
const { data: allProjects } = await useLazyAsyncData("moderation-projects", async () => {
const startTime = performance.now();
let currentOffset = 0;
const PROJECT_ENDPOINT_COUNT = 350;
const allProjects: ModerationProject[] = [];
const enrichmentPromises: Promise<ModerationProject[]>[] = [];
while (true) {
const projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as any[];
if (projects.length === 0) break;
const enrichmentPromise = enrichProjectBatch(projects);
enrichmentPromises.push(enrichmentPromise);
currentOffset += projects.length;
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
allProjects.push(...completed.flat());
}
if (projects.length < PROJECT_ENDPOINT_COUNT) break;
}
const remainingBatches = await Promise.all(enrichmentPromises);
allProjects.push(...remainingBatches.flat());
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
);
return allProjects;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-current-filter-type", () => "All projects");
const filterTypes: readonly string[] = readonly([
"All projects",
"Modpacks",
"Mods",
"Resource Packs",
"Data Packs",
"Plugins",
"Shaders",
]);
const currentSortType = useLocalStorage("moderation-current-sort-type", () => "Oldest");
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null;
return new Fuse(allProjects.value, {
keys: [
{
name: "project.title",
weight: 3,
},
{
name: "project.slug",
weight: 2,
},
{
name: "project.description",
weight: 2,
},
{
name: "project.project_type",
weight: 1,
},
"owner.user.username",
"org.name",
"org.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null;
return fuse.value.search(query.value).map((result) => result.item);
});
const baseFiltered = computed(() => {
if (!allProjects.value) return [];
return query.value && searchResults.value ? searchResults.value : [...allProjects.value];
});
const typeFiltered = computed(() => {
if (currentFilterType.value === "All projects") return baseFiltered.value;
const filterMap: Record<string, string> = {
Modpacks: "modpack",
Mods: "mod",
"Resource Packs": "resourcepack",
"Data Packs": "datapack",
Plugins: "plugin",
Shaders: "shader",
};
const projectType = filterMap[currentFilterType.value];
if (!projectType) return baseFiltered.value;
return baseFiltered.value.filter((queueItem) =>
queueItem.project.project_types.includes(projectType),
);
});
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value];
if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateA - dateB;
});
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime();
const dateB = new Date(b.project.queued || b.project.published || 0).getTime();
return dateB - dateA;
});
}
return filtered;
});
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredProjects.value.slice(start, end);
});
function goToPage(page: number) {
currentPage.value = page;
}
function moderateAllInFilter() {
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id));
navigateTo({
name: "type-id",
params: {
type: "project",
id: moderationStore.getCurrentProjectId(),
},
state: {
showChecklist: true,
},
});
}
</script>

View File

@@ -1,17 +0,0 @@
<template>
<ReportView
:auth="auth"
:report-id="route.params.id"
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
/>
</template>
<script setup>
import ReportView from "~/components/ui/report/ReportView.vue";
const auth = await useAuth();
const route = useNativeRoute();
useHead({
title: `Report ${route.params.id} - Modrinth`,
});
</script>

View File

@@ -1,16 +0,0 @@
<template>
<div>
<section class="universal-card">
<h2>Reports</h2>
<ReportsList :auth="auth" moderation />
</section>
</div>
</template>
<script setup>
import ReportsList from "~/components/ui/report/ReportsList.vue";
const auth = await useAuth();
useHead({
title: "Reports - Modrinth",
});
</script>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { Report } from "@modrinth/utils";
import { enrichReportBatch } from "~/helpers/moderation.ts";
import ModerationReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
const { params } = useRoute();
const reportId = params.id as string;
const { data: report } = await useAsyncData(`moderation-report-${reportId}`, async () => {
try {
const report = (await useBaseFetch(`report/${reportId}`, { apiVersion: 3 })) as Report;
const enrichedReport = (await enrichReportBatch([report]))[0];
return enrichedReport;
} catch (error) {
console.error("Error fetching report:", error);
throw createError({
statusCode: 404,
statusMessage: "Report not found",
});
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<ModerationReportCard v-if="report" :report="report" />
</div>
</template>

View File

@@ -0,0 +1,290 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="goToPage(1)"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="goToPage(1)"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import type { Report } from "@modrinth/utils";
import Fuse from "fuse.js";
import type { ExtendedReport } from "@modrinth/moderation";
import ReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
import { enrichReportBatch } from "~/helpers/moderation.ts";
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.search.placeholder",
defaultMessage: "Search...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
});
const { data: allReports } = await useLazyAsyncData("new-moderation-reports", async () => {
const startTime = performance.now();
let currentOffset = 0;
const REPORT_ENDPOINT_COUNT = 350;
const allReports: ExtendedReport[] = [];
const enrichmentPromises: Promise<ExtendedReport[]>[] = [];
while (true) {
const reports = (await useBaseFetch(
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ apiVersion: 3 },
)) as Report[];
if (reports.length === 0) break;
const enrichmentPromise = enrichReportBatch(reports);
enrichmentPromises.push(enrichmentPromise);
currentOffset += reports.length;
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
allReports.push(...completed.flat());
}
if (reports.length < REPORT_ENDPOINT_COUNT) break;
}
const remainingBatches = await Promise.all(enrichmentPromises);
allReports.push(...remainingBatches.flat());
const endTime = performance.now();
const duration = endTime - startTime;
console.debug(
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
);
return allReports;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-reports-filter-type", () => "All");
const filterTypes: readonly string[] = readonly(["All", "Unread", "Read"]);
const currentSortType = useLocalStorage("moderation-reports-sort-type", () => "Oldest");
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null;
return new Fuse(allReports.value, {
keys: [
{
name: "id",
weight: 3,
},
{
name: "body",
weight: 3,
},
{
name: "report_type",
weight: 3,
},
{
name: "item_id",
weight: 2,
},
{
name: "reporter_user.username",
weight: 2,
},
"project.name",
"project.slug",
"user.username",
"version.name",
"target.name",
"target.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const memberRoleMap = computed(() => {
if (!allReports.value?.length) return new Map();
const map = new Map();
for (const report of allReports.value) {
if (report.thread?.members?.length) {
const roleMap = new Map();
for (const member of report.thread.members) {
roleMap.set(member.id, member.role);
}
map.set(report.id, roleMap);
}
}
return map;
});
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null;
return fuse.value.search(query.value).map((result) => result.item);
});
const baseFiltered = computed(() => {
if (!allReports.value) return [];
return query.value && searchResults.value ? searchResults.value : [...allReports.value];
});
const typeFiltered = computed(() => {
if (currentFilterType.value === "All") return baseFiltered.value;
return baseFiltered.value.filter((report) => {
const messages = report.thread?.messages || [];
if (messages.length === 0) {
return currentFilterType.value === "Unread";
}
const lastMessage = messages[messages.length - 1];
if (!lastMessage.author_id) return false;
const roleMap = memberRoleMap.value.get(report.id);
if (!roleMap) return false;
const authorRole = roleMap.get(lastMessage.author_id);
const isModeratorMessage = authorRole === "moderator" || authorRole === "admin";
return currentFilterType.value === "Read" ? isModeratorMessage : !isModeratorMessage;
});
});
const filteredReports = computed(() => {
const filtered = [...typeFiltered.value];
if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
} else {
filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
}
return filtered;
});
const paginatedReports = computed(() => {
if (!filteredReports.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredReports.value.slice(start, end);
});
function goToPage(page: number) {
currentPage.value = page;
}
</script>

View File

@@ -1,301 +0,0 @@
<template>
<section class="universal-card">
<h2>Review projects</h2>
<div class="input-group">
<Chips
v-model="projectType"
:items="projectTypes"
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x) + 's')"
/>
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
<SortDescIcon />
Sorting by oldest
</button>
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
<SortAscIcon />
Sorting by newest
</button>
<button
class="btn btn-highlight"
:disabled="projectsFiltered.length === 0"
@click="goToProjects()"
>
<ScaleIcon />
Start moderating
</button>
</div>
<p v-if="projectType !== 'all'" class="project-count">
Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
projects in the queue.
</p>
<p v-else class="project-count">There are {{ projects.length }} projects in the queue.</p>
<p v-if="projectsOver24Hours.length > 0" class="warning project-count">
<IssuesIcon />
{{ projectsOver24Hours.length }} {{ projectTypePlural }}
have been in the queue for over 24 hours.
</p>
<p v-if="projectsOver48Hours.length > 0" class="danger project-count">
<IssuesIcon />
{{ projectsOver48Hours.length }} {{ projectTypePlural }}
have been in the queue for over 48 hours.
</p>
<div
v-for="project in projectsFiltered.sort((a, b) => {
if (oldestFirst) {
return b.age - a.age;
} else {
return a.age - b.age;
}
})"
:key="`project-${project.id}`"
class="universal-card recessed project"
>
<div class="project-title">
<div class="mobile-row">
<nuxt-link :to="`/project/${project.id}`" class="iconified-stacked-link">
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
<span class="stacked">
<span class="title">{{ project.name }}</span>
<span>{{ formatProjectType(project.inferred_project_type) }}</span>
</span>
</nuxt-link>
</div>
<div class="mobile-row">
by
<nuxt-link
v-if="project.owner"
:to="`/user/${project.owner.user.id}`"
class="iconified-link"
>
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
<span>{{ project.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="project.org"
:to="`/organization/${project.org.id}`"
class="iconified-link"
>
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
<span>{{ project.org.name }}</span>
</nuxt-link>
</div>
<div class="mobile-row">
is requesting to be
<ProjectStatusBadge
:status="project.requested_status ? project.requested_status : 'approved'"
/>
</div>
</div>
<div class="input-group">
<nuxt-link :to="`/project/${project.id}`" class="iconified-button raised-button">
<EyeIcon />
View project
</nuxt-link>
</div>
<span v-if="project.queued" :class="`submitter-info ${project.age_warning}`">
<IssuesIcon v-if="project.age_warning" />
Submitted
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
formatRelativeTime(project.queued)
}}</span>
</span>
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
</div>
</section>
</template>
<script setup>
import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui";
import {
UnknownIcon,
EyeIcon,
SortAscIcon,
SortDescIcon,
IssuesIcon,
ScaleIcon,
} from "@modrinth/assets";
import { formatProjectType } from "@modrinth/utils";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
useHead({
title: "Review projects - Modrinth",
});
const app = useNuxtApp();
const router = useRouter();
const now = app.$dayjs();
const TIME_24H = 86400000;
const TIME_48H = TIME_24H * 2;
const formatRelativeTime = useRelativeTime();
const { data: projects } = await useAsyncData("moderation/projects?count=1000", () =>
useBaseFetch("moderation/projects?count=1000", { internal: true }),
);
const members = ref([]);
const projectType = ref("all");
const oldestFirst = ref(true);
const projectsFiltered = computed(() =>
projects.value.filter(
(x) =>
projectType.value === "all" ||
app.$getProjectTypeForUrl(x.project_types[0], x.loaders) === projectType.value,
),
);
const projectsOver24Hours = computed(() =>
projectsFiltered.value.filter((project) => project.age >= TIME_24H && project.age < TIME_48H),
);
const projectsOver48Hours = computed(() =>
projectsFiltered.value.filter((project) => project.age >= TIME_48H),
);
const projectTypePlural = computed(() =>
projectType.value === "all"
? "projects"
: (formatProjectType(projectType.value) + "s").toLowerCase(),
);
const projectTypes = computed(() => {
const set = new Set();
set.add("all");
if (projects.value) {
for (const project of projects.value) {
set.add(project.inferred_project_type);
}
}
return [...set];
});
if (projects.value) {
const teamIds = projects.value.map((x) => x.team_id);
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
const [{ data: teams }, { data: orgs }] = await Promise.all([
useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
),
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
}),
),
]);
if (teams.value) {
members.value = teams.value;
projects.value = projects.value.map((project) => {
project.owner = members.value
? members.value.flat().find((x) => x.team_id === project.team_id && x.role === "Owner")
: null;
project.org = orgs.value ? orgs.value.find((x) => x.id === project.organization) : null;
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE;
project.age_warning = "";
if (project.age > TIME_24H * 2) {
project.age_warning = "danger";
} else if (project.age > TIME_24H) {
project.age_warning = "warning";
}
project.inferred_project_type = app.$getProjectTypeForUrl(
project.project_types[0],
project.loaders,
);
return project;
});
}
}
async function goToProjects() {
const project = projectsFiltered.value[0];
await router.push({
name: "type-id",
params: {
type: project.project_types[0],
id: project.slug ? project.slug : project.id,
},
state: {
showChecklist: true,
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
},
});
}
</script>
<style lang="scss" scoped>
.project {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
@media screen and (min-width: 650px) {
display: grid;
grid-template: "title action" "date action";
grid-template-columns: 1fr auto;
}
}
.submitter-info {
margin: 0;
grid-area: date;
svg {
vertical-align: top;
}
}
.warning {
color: var(--color-orange);
}
.danger {
color: var(--color-red);
font-weight: bold;
}
.project-count {
margin-block: var(--spacing-card-md);
svg {
vertical-align: top;
}
}
.input-group {
grid-area: action;
}
.project-title {
display: flex;
gap: var(--spacing-card-xs);
align-items: center;
flex-wrap: wrap;
.mobile-row {
display: contents;
}
@media screen and (max-width: 800px) {
flex-direction: column;
align-items: flex-start;
.mobile-row {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
flex-wrap: wrap;
}
}
}
:deep(.avatar) {
flex-shrink: 0;
&.size-xs {
margin-right: var(--spacing-card-xs);
}
}
</style>

View File

@@ -0,0 +1,386 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<div class="iconified-input flex-1 lg:max-w-md">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
v-model="query"
class="h-[40px]"
autocomplete="off"
spellcheck="false"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
@input="updateSearchResults()"
/>
<Button v-if="query" class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<DropdownSelect
v-slot="{ selected }"
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
:name="formatMessage(messages.filterBy)"
:options="filterTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<FilterIcon class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:name="formatMessage(messages.sortBy)"
:options="sortTypes as unknown[]"
@change="updateSearchResults()"
>
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
<SortDescIcon v-else class="size-4 flex-shrink-0" />
<span class="truncate">{{ selected }}</span>
</span>
</DropdownSelect>
</div>
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="mt-4 flex flex-col gap-2">
<DelphiReportCard
v-for="report in paginatedReports"
:key="report.version.id"
:report="report"
/>
<div
v-if="!paginatedReports || paginatedReports.length === 0"
class="universal-card h-24 animate-pulse"
></div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { useLocalStorage } from "@vueuse/core";
import type { TeamMember, Organization, DelphiReport, Project, Version } from "@modrinth/utils";
import Fuse from "fuse.js";
import type { OwnershipTarget, ExtendedDelphiReport } from "@modrinth/moderation";
import DelphiReportCard from "~/components/ui/moderation/ModerationDelphiReportCard.vue";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
const { formatMessage } = useVIntl();
const route = useRoute();
const router = useRouter();
const messages = defineMessages({
searchPlaceholder: {
id: "moderation.technical.search.placeholder",
defaultMessage: "Search tech reviews...",
},
filterBy: {
id: "moderation.filter.by",
defaultMessage: "Filter by",
},
sortBy: {
id: "moderation.sort.by",
defaultMessage: "Sort by",
},
});
async function getProjectQuicklyForMock(projectId: string): Promise<Project> {
return (await useBaseFetch(`project/${projectId}`)) as Project;
}
async function getVersionQuicklyForMock(versionId: string): Promise<Version> {
return (await useBaseFetch(`version/${versionId}`)) as Version;
}
const mockDelphiReports: DelphiReport[] = [
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/ASMEventHandlerHelper.java",
priority_score: 29,
status: "pending",
detected_at: "2025-04-01T12:00:00Z",
} as DelphiReport,
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/SomeOtherFile.java",
priority_score: 48,
status: "rejected",
detected_at: "2025-03-02T12:00:00Z",
} as DelphiReport,
{
project: await getProjectQuicklyForMock("7MoE34WK"),
version: await getVersionQuicklyForMock("cTkKLWgA"),
trace_type: "url_usage",
file_path: "me/decce/gnetum/YetAnotherFile.java",
priority_score: 15,
status: "approved",
detected_at: "2025-02-03T12:00:00Z",
} as DelphiReport,
];
const { data: allReports } = await useAsyncData("moderation-tech-reviews", async () => {
// TODO: replace with actual API call
const delphiReports = mockDelphiReports;
if (delphiReports.length === 0) {
return [];
}
const teamIds = [...new Set(delphiReports.map((report) => report.project.team).filter(Boolean))];
const orgIds = [
...new Set(delphiReports.map((report) => report.project.organization).filter(Boolean)),
];
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
teamIds.length > 0
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: Promise.resolve([]),
orgIds.length > 0
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
apiVersion: 3,
})
: Promise.resolve([]),
]);
const orgTeamIds = orgsData.map((org) => org.team_id).filter(Boolean);
const orgTeamsData: TeamMember[][] =
orgTeamIds.length > 0
? await fetchSegmented(orgTeamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
: [];
const teamMap = new Map<string, TeamMember[]>();
const orgMap = new Map<string, Organization>();
teamsData.forEach((team) => {
let teamId = null;
for (const member of team) {
teamId = member.team_id;
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team);
break;
}
}
});
orgTeamsData.forEach((team) => {
let teamId = null;
for (const member of team) {
teamId = member.team_id;
if (!teamMap.has(teamId)) {
teamMap.set(teamId, team);
break;
}
}
});
orgsData.forEach((org: Organization) => {
orgMap.set(org.id, org);
});
const extendedReports: ExtendedDelphiReport[] = delphiReports.map((report) => {
let target: OwnershipTarget | undefined;
const project = report.project;
if (project) {
let owner: TeamMember | null = null;
let org: Organization | null = null;
if (project.team) {
const teamMembers = teamMap.get(project.team);
if (teamMembers) {
owner = teamMembers.find((member) => member.role === "Owner") || null;
}
}
if (project.organization) {
org = orgMap.get(project.organization) || null;
}
if (org) {
target = {
name: org.name,
avatar_url: org.icon_url,
type: "organization",
slug: org.slug,
};
} else if (owner) {
target = {
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: "user",
slug: owner.user.username,
};
}
}
return {
...report,
target,
};
});
extendedReports.sort((a, b) => b.priority_score - a.priority_score);
return extendedReports;
});
const query = ref(route.query.q?.toString() || "");
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query };
if (newQuery) {
currentQuery.q = newQuery;
} else {
delete currentQuery.q;
}
router.replace({
path: route.path,
query: currentQuery,
});
},
{ immediate: false },
);
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || "";
if (query.value !== newValue) {
query.value = newValue;
}
},
);
const currentFilterType = useLocalStorage("moderation-tech-reviews-filter-type", () => "Pending");
const filterTypes: readonly string[] = readonly(["All", "Pending", "Approved", "Rejected"]);
const currentSortType = useLocalStorage("moderation-tech-reviews-sort-type", () => "Priority");
const sortTypes: readonly string[] = readonly(["Priority", "Oldest", "Newest"]);
const currentPage = ref(1);
const itemsPerPage = 15;
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
const fuse = computed(() => {
if (!allReports.value || allReports.value.length === 0) return null;
return new Fuse(allReports.value, {
keys: [
{
name: "version.id",
weight: 3,
},
{
name: "version.version_number",
weight: 3,
},
{
name: "project.title",
weight: 3,
},
{
name: "project.slug",
weight: 3,
},
{
name: "version.files.filename",
weight: 2,
},
{
name: "trace_type",
weight: 2,
},
{
name: "content",
weight: 0.5,
},
"file_path",
"project.id",
"target.name",
"target.slug",
],
includeScore: true,
threshold: 0.4,
});
});
const filteredReports = computed(() => {
if (!allReports.value) return [];
let filtered;
if (query.value && fuse.value) {
const results = fuse.value.search(query.value);
filtered = results.map((result) => result.item);
} else {
filtered = [...allReports.value];
}
if (currentFilterType.value === "Pending") {
filtered = filtered.filter((report) => report.status === "pending");
} else if (currentFilterType.value === "Approved") {
filtered = filtered.filter((report) => report.status === "approved");
} else if (currentFilterType.value === "Rejected") {
filtered = filtered.filter((report) => report.status === "rejected");
}
if (currentSortType.value === "Priority") {
filtered.sort((a, b) => b.priority_score - a.priority_score);
} else if (currentSortType.value === "Oldest") {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime();
const dateB = new Date(b.detected_at).getTime();
return dateA - dateB;
});
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.detected_at).getTime();
const dateB = new Date(b.detected_at).getTime();
return dateB - dateA;
});
}
return filtered;
});
const paginatedReports = computed(() => {
if (!filteredReports.value) return [];
const start = (currentPage.value - 1) * itemsPerPage;
const end = start + itemsPerPage;
return filteredReports.value.slice(start, end);
});
function updateSearchResults() {
currentPage.value = 1;
}
function goToPage(page: number) {
currentPage.value = page;
}
</script>

View File

@@ -0,0 +1,3 @@
<template>
<p>Not yet implemented.</p>
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { Avatar, ButtonStyled } from "@modrinth/ui";
import { RssIcon, GitGraphIcon } from "@modrinth/assets";
import dayjs from "dayjs";
import { articles as rawArticles } from "@modrinth/blog";
import { computed } from "vue";
import type { User } from "@modrinth/utils";
import ShareArticleButtons from "~/components/ui/ShareArticleButtons.vue";
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
@@ -20,7 +21,21 @@ if (!rawArticle) {
});
}
const html = await rawArticle.html();
const authorsUrl = `users?ids=${JSON.stringify(rawArticle.authors)}`;
const [authors, html] = await Promise.all([
rawArticle.authors
? useAsyncData(authorsUrl, () => useBaseFetch(authorsUrl)).then((data) => {
const users = data.data as Ref<User[]>;
users.value.sort((a, b) => {
return rawArticle.authors.indexOf(a.id) - rawArticle.authors.indexOf(b.id);
});
return users;
})
: Promise.resolve(),
rawArticle.html(),
]);
const article = computed(() => ({
...rawArticle,
@@ -34,6 +49,8 @@ const article = computed(() => ({
html,
}));
const authorCount = computed(() => authors?.value?.length ?? 0);
const articleTitle = computed(() => article.value.title);
const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`);
@@ -83,9 +100,35 @@ useSeoMeta({
<article class="mt-6 flex flex-col gap-4 px-6">
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">{{ article.title }}</h2>
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
<div class="mt-auto text-sm text-secondary sm:text-base">
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}
<div class="mt-auto flex flex-wrap items-center gap-1 text-sm text-secondary sm:text-base">
<template v-for="(author, index) in authors" :key="`author-${author.id}`">
<span v-if="authorCount - 1 === index && authorCount > 1">and</span>
<span class="flex items-center">
<nuxt-link
:to="`/user/${author.id}`"
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
>
<Avatar :src="author.avatar_url" circle size="24px" />
{{ author.username }}
</nuxt-link>
<span v-if="(authors?.length ?? 0) > 2 && index !== authorCount - 1">,</span>
</span>
</template>
<template v-if="!authors || authorCount === 0">
<nuxt-link
to="/organization/modrinth"
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
>
<Avatar src="https://cdn-raw.modrinth.com/modrinth-icon-96.webp" size="24px" />
Modrinth Team
</nuxt-link>
</template>
<span class="hidden md:block"></span>
<span class="hidden md:block"> {{ dayjsDate.format("MMMM D, YYYY") }}</span>
</div>
<span class="text-sm text-secondary sm:text-base md:hidden">
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}</span
>
<ShareArticleButtons :title="article.title" :url="articleUrl" />
<img
:src="article.thumbnail"

View File

@@ -149,7 +149,8 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.main-hero {
background: linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
background:
linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
var(--color-accent-contrast);
margin-top: -5rem;
padding: 11.25rem 1rem 8rem;

View File

@@ -45,8 +45,9 @@
<h2
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]"
>
Modrinth Servers is the easiest way to host your own Minecraft server. Seamlessly install
and play your favorite mods and modpacks, all within the Modrinth platform.
Modrinth Servers is the easiest way to host your own Minecraft: Java Edition server.
Seamlessly install and play your favorite mods and modpacks, all within the Modrinth
platform.
</h2>
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
<div
@@ -427,11 +428,8 @@
Do Modrinth Servers have DDoS protection?
</summary>
<p class="m-0 ml-6 leading-[160%]">
Yes. All Modrinth Servers come with DDoS protection powered by
<a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank"
>OVHcloud® Anti-DDoS infrastructure</a
>
which has over 17Tbps capacity. Your server is safe on Modrinth.
Yes. All Modrinth Servers come with DDoS protection, with up to 17Tbps capacity in
some locations.
</p>
</details>
@@ -443,8 +441,9 @@
Where are Modrinth Servers located? Can I choose a region?
</summary>
<p class="m-0 ml-6 leading-[160%]">
We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg,
Germany. More regions to come in the future!
We have servers available in North America and Europe at the moment that you can
choose upon purchase. More regions to come in the future! If you'd like to switch
your region, please contact support.
</p>
</details>
@@ -461,7 +460,7 @@
</p>
</details>
<details pyro-hash="players" class="group" :open="$route.hash === '#players'">
<details pyro-hash="performance" class="group" :open="$route.hash === '#performance'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -482,7 +481,7 @@
</p>
</details>
<details pyro-hash="players" class="group" :open="$route.hash === '#prices'">
<details pyro-hash="prices" class="group" :open="$route.hash === '#prices'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -493,18 +492,35 @@
All prices are listed in United States Dollars (USD).
</p>
</details>
<details pyro-hash="versions" class="group" :open="$route.hash === '#versions'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
</span>
What Minecraft versions and loaders can be used?
</summary>
<p class="m-0 ml-6 leading-[160%]">
Modrinth Servers can run any version of Minecraft: Java Edition going all the way
back to version 1.2.5, including snapshot versions.
</p>
<p class="m-0 ml-6 mt-3 leading-[160%]">
We also support a wide range of mod and plugin loaders, including Fabric, Quilt,
Forge, and NeoForge for mods, as well as Paper and Purpur for plugins. Availability
depends on whether the mod or plugin loader supports the selected Minecraft version.
</p>
</details>
</div>
</div>
</div>
</section>
<section
id="plan"
pyro-hash="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"
>
<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 items-center gap-8 text-center">
<div id="plan" class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center">
<h1 class="relative m-0 text-4xl leading-[120%] md:text-7xl">
There's a server for everyone
</h1>
@@ -534,6 +550,8 @@
<span v-else></span>
</div>
<MedalPlanPromotion v-if="flags.enableMedalPromotion" />
<ul class="m-0 flex w-full grid-cols-3 flex-col gap-8 p-0 lg:grid">
<ServerPlanSelector
:capacity="capacityStatuses?.small?.available"
@@ -631,11 +649,13 @@ import { formatPrice } from "@modrinth/utils";
import { useVIntl } from "@vintl/vintl";
import { products } from "~/generated/state.json";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import MedalPlanPromotion from "~/components/ui/servers/marketing/MedalPlanPromotion.vue";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
import OptionGroup from "~/components/ui/OptionGroup.vue";
const { locale } = useVIntl();
const flags = useFeatureFlags();
const billingPeriods = ref(["monthly", "quarterly"]);
const billingPeriod = ref(billingPeriods.value.includes("quarterly") ? "quarterly" : "monthly");
@@ -719,31 +739,32 @@ async function fetchCapacityStatuses(customProduct = null) {
product.metadata.ram < min.metadata.ram ? product : min,
),
];
const capacityChecks = productsToCheck.map((product) =>
useServersFetch("stock", {
method: "POST",
body: {
cpu: product.metadata.cpu,
memory_mb: product.metadata.ram,
swap_mb: product.metadata.swap,
storage_mb: product.metadata.storage,
},
bypassAuth: true,
}),
);
const results = await Promise.all(capacityChecks);
const capacityChecks = [];
for (const product of productsToCheck) {
capacityChecks.push(
useServersFetch("stock", {
method: "POST",
body: {
cpu: product.metadata.cpu,
memory_mb: product.metadata.ram,
swap_mb: product.metadata.swap,
storage_mb: product.metadata.storage,
},
bypassAuth: true,
}),
);
}
if (customProduct?.metadata) {
return {
custom: results[0],
custom: await capacityChecks[0],
};
} else {
return {
small: results[0],
medium: results[1],
large: results[2],
custom: results[3],
small: await capacityChecks[0],
medium: await capacityChecks[1],
large: await capacityChecks[2],
custom: await capacityChecks[3],
};
}
} catch (error) {
@@ -760,6 +781,11 @@ async function fetchCapacityStatuses(customProduct = null) {
const { data: capacityStatuses, refresh: refreshCapacity } = await useAsyncData(
"ServerCapacityAll",
fetchCapacityStatuses,
{
getCachedData() {
return null; // Dont cache stock data.
},
},
);
const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0);
@@ -906,10 +932,14 @@ const selectProduct = async (product) => {
}
};
const planQuery = () => {
if (route.query.plan) {
document.getElementById("plan").scrollIntoView();
selectProduct(route.query.plan);
const planQuery = async () => {
if ("plan" in route.query) {
await nextTick();
const planElement = document.getElementById("plan");
if (planElement) {
planElement.scrollIntoView({ behavior: "smooth" });
await selectProduct(route.query.plan);
}
}
};

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