Compare commits

...

50 Commits

Author SHA1 Message Date
fetch
11a2ad61b8 Include region in user subscription metadata 2025-06-02 00:26:34 -04:00
Emma Alexia
7b535a1c2a Enable charity payouts through Tremendous (#3732) 2025-06-01 23:53:45 +00:00
Alejandro González
0aa76567a6 docs(issue template): do not skip closed issues in link to existing issues (#3728)
When an issue has been already handled by our part, and thus gets
closed, but affects many users and the fix takes a while to be rolled
out, it usually happens that those who notice the matter later on don't
notice previous reports and create duplicate issues.

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

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

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

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

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

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

* chore(labrinth): simplify `capitalize_first` implementation

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

This addresses an unintended behavior change on
157647faf2.

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

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

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

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

* Remove hardcoded default server region
2025-05-26 21:21:52 +00:00
Emma Alexia
74cf3f076e Automatically fail payments that are older than 30 days (#3697) 2025-05-25 19:36:19 +00:00
Emma Alexia
84adf79564 Fix resubscription of servers with failed payments (#3696)
* Fix resubscription of servers with failed payments

Resolves MOD-55

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

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

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

* Run sqlx prepare

---------

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

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

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

* fix: correct unintended regression on version and project validation

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

* fix: lint issues

* fix: deduplicate SCSS with variant map

* feat: color shades + reduced scss

* feat: fix theming

* chore: Remove shades-generator.ts

* fix: lint issues

---------

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

* Update bitflags from 2.9.0 to 2.9.1

* Fix temp directory leak in check_java_at_filepath

* Fix build

* Fix lint

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

* chore: fix Clippy lint

* tweak: optimize out dependency on OpenSSL source build

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

* chore(labrinth): drop now unused dependency

* Update zip because 2.6.1 got yanked

* Downgrade weezl to 0.1.8

* Mention that p256 is also a blocker for rand 0.9

* chore: sidestep GitHub review requirements

* chore: sidestep GitHub review requirements (2)

* chore: sidestep GitHub review requirements (3)

---------

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

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

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

Fixes #2766

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

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

---------

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

This reverts commit e4adbb9469.

* Fix organization projects route properly

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

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

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

* fix copy-paste error

---------

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

* Update some Labrinth dependencies

* Update some Labrinth dependencies

* Update zip in Labrinth

* Update itertools in Labrinth

* Update validator in labrinth

* Update thiserror in labrinth

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

* Update totp-rs and spdx in labrinth

* Update maxminddb and tar in labrinth

* Update sentry and sentry-actix in labrinth

* Update image in labrinth

* Update lettre in labrinth

* Update derive-new and rust_iso3166 in labrinth

* Update async-stripe and json-patch in labrinth

* Update clap and iana-time-zone in labrinth

* Update labrinth to Rust 2024

* Cargo fmt

* Just do a full cargo update

* Update daedelus to Rust 2024

* Update daedelus_client to Rust 2024

* Set the formatting edition to 2024

* Fix formatting

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

Closes: #1533

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

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

* fix(frontend): lang reshuffle

* feat: rinthbot

* fix: lint issues

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

* fix: lint issues

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

* Remove explicit backup provider naming from frontend

* CF placeholder

* Use regex for CF links

* Lint

* Add unzip warning for conflicting files, fix hydration error

* Adjust conflict modal ui

* Fix old queued ops sticking around, remove conflict warning

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

Closes: #1371

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

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

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

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

* Box the S3 error for daedelus_client ErrorKind::S3

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

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

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

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

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

* fix: re-enable app window shadows on Linux

* chore: log `window.set_shadow` errors

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

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

---------

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

* docs: meniton copying .env.example

* chore: app-lib env example

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

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

---------

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

* Misc fixes

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

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

* Create AttachedWorldData

* Change AttachedWorldData interface

* Rename WorldType::World to WorldType::Singleplayer

* Implement world display status system

* Fix Minecraft font

* Fix set_world_display_status Tauri error

* Add 'Play instance' option

* Add option to disable worlds showing in Home

* Fixes

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

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

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

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

* Update dependencies in app-lib that had breaking updates

* Update dependencies in app that had breaking updates

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

* Also update tauri-plugin-opener

* Update app-lib to Rust 2024

* Non-breaking updates in ariadne

* Breaking updates in ariadne

* Ariadne Rust 2024

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

* Make recent worlds go into grid when display is huge

* Fix lint

* Remove redundant media query

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

* Clippy fix in app-lib

* Clippy fix in app

* Clippy fix

* More Clippy fixes

* Fix Prettier lints

* Undo `from_string` changes

* Update macos dependencies

* Apply updates to app-playground as well

* Update Wry + Tauri

* Update sysinfo

* Update theseus_gui to Rust 2024

* Downgrade rand in ariadne to fix labrinth

Labrinth can't use rand 0.9 due to argon2

* Cargo format

---------

Signed-off-by: Josiah Glosson <soujournme@gmail.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Alejandro González <me@alegon.dev>
2025-05-02 09:51:17 +00:00
Prospector
20b616a7c4 Update changelog 2025-05-01 18:25:46 -07:00
395 changed files with 8847 additions and 8468 deletions

View File

@@ -6,7 +6,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true

View File

@@ -6,7 +6,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true

View File

@@ -6,7 +6,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true

View File

@@ -7,7 +7,7 @@ body:
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate feature requests
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
required: true
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
required: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

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

5227
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,183 @@
[workspace]
resolver = '2'
resolver = "2"
members = [
'./packages/app-lib',
'./apps/app-playground',
'./apps/app',
'./apps/labrinth',
'./apps/daedalus_client',
'./packages/daedalus',
'./packages/ariadne',
"apps/app",
"apps/app-playground",
"apps/daedalus_client",
"apps/labrinth",
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
]
[workspace.dependencies]
actix-cors = "0.7.1"
actix-files = "0.6.6"
actix-http = "3.11.0"
actix-multipart = "0.7.2"
actix-rt = "2.10.0"
actix-web = "4.11.0"
actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.23", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.88"
async-tungstenite = { version = "0.29.1", default-features = false, features = [
"futures-03-sink",
] }
async-walkdir = "2.1.0"
async_zip = "0.0.17"
base64 = "0.22.1"
bitflags = "2.9.0"
bytes = "1.10.1"
censor = "0.3.0"
chrono = "0.4.41"
clap = "4.5.38"
clickhouse = "0.13.2"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
deadpool-redis = "0.21.1"
dirs = "6.0.0"
discord-rich-presence = "0.2.5"
dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
enumset = "1.1.6"
flate2 = "1.1.1"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper-tls = "0.6.0"
hyper-util = "0.1.11"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0"
indicatif = "0.17.11"
itertools = "0.14.0"
jemalloc_pprof = "0.7.0"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.16", default-features = false, features = [
"builder",
"hostname",
"pool",
"ring",
"rustls",
"rustls-native-certs",
"smtp-transport",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.28.0", default-features = false }
murmur2 = "0.1.0"
native-dialog = "0.9.0"
notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2"
paste = "1.0.15"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.37.5"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "0.31.0"
regex = "1.11.1"
reqwest = { version = "0.12.15", default-features = false }
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
] }
rust_decimal = { version = "1.37.1", features = [
"serde-with-float",
"serde-with-str",
] }
rust_iso3166 = "0.1.14"
rusty-money = "0.4.1"
sentry = { version = "0.38.1", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"panic",
"reqwest",
"rustls",
] }
sentry-actix = "0.38.1"
serde = "1.0.219"
serde-xml-rs = "0.8.0" # Also an XML (de)serializer, consider dropping yaserde in favor of this
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.140"
serde_with = "3.12.0"
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.8"
sqlx = { version = "0.8.5", default-features = false }
sysinfo = { version = "0.35.1", default-features = false }
tar = "0.4.44"
tauri = "2.5.1"
tauri-build = "2.2.0"
tauri-plugin-deep-link = "2.2.1"
tauri-plugin-dialog = "2.2.1"
tauri-plugin-opener = "2.2.6"
tauri-plugin-os = "2.2.1"
tauri-plugin-single-instance = "2.2.3"
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
"rustls-tls",
"zip",
] }
tauri-plugin-window-state = "2.2.2"
tempfile = "3.20.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.45.0"
tokio-stream = "0.1.17"
tokio-util = "0.7.15"
totp-rs = "5.7.0"
tracing = "0.1.41"
tracing-actix-web = "0.7.18"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
url = "2.5.4"
urlencoding = "2.1.3"
uuid = "1.16.0"
validator = "0.20.0"
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "3.0.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
"zstd",
] }
zxcvbn = "3.1.0"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
# Optimize for speed and reduce size on release builds
[profile.release]
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
lto = true # Enables link to optimizations
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
lto = true # Enables link to optimizations
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
[profile.dev.package.sqlx-macros]
opt-level = 3
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }

View File

@@ -17,12 +17,12 @@
"@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0",
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-window-state": "^2.2.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@vintl/vintl": "^4.4.1",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",

View File

@@ -19,7 +19,14 @@ import {
WorldIcon,
XIcon,
} from '@modrinth/assets'
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
import {
Avatar,
Button,
ButtonStyled,
Notifications,
OverflowMenu,
useRelativeTime,
} from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
@@ -62,6 +69,8 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
const formatRelativeTime = useRelativeTime()
const themeStore = useTheming()
const news = ref([])
@@ -590,7 +599,7 @@ function handleAuxClick(e) {
</h4>
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
{{ dayjs(item.date).fromNow() }}
{{ formatRelativeTime(dayjs(item.date).toISOString()) }}
</p>
</a>
<hr

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 937 KiB

After

Width:  |  Height:  |  Size: 270 KiB

View File

@@ -9,7 +9,7 @@ import {
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled } from '@modrinth/ui'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
@@ -19,10 +19,9 @@ import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { formatCategory } from '@modrinth/utils'
dayjs.extend(relativeTime)
const formatRelativeTime = useRelativeTime()
const props = defineProps({
instance: {
@@ -173,7 +172,9 @@ onUnmounted(() => unlisten())
</div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon />
<span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
<span class="text-sm">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</span>
</div>
</div>
</template>

View File

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

View File

@@ -8,7 +8,14 @@ import {
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
import {
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
useRelativeTime,
} from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { showProfileInFolder } from '@/helpers/utils'
@@ -25,6 +32,7 @@ import { handleError } from '@/store/notifications'
import { process_listener } from '@/helpers/events'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter()
@@ -144,7 +152,7 @@ onUnmounted(() => {
<template v-if="instance.last_played">
{{
formatMessage(commonMessages.playedLabel, {
time: dayjs(instance.last_played).fromNow(),
time: formatRelativeTime(instance.last_played.toISOString()),
})
}}
</template>

View File

@@ -7,6 +7,14 @@ import {
showWorldInFolder,
} from '@/helpers/worlds.ts'
import { formatNumber } from '@modrinth/utils'
import {
useRelativeTime,
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
} from '@modrinth/ui'
import {
IssuesIcon,
EyeIcon,
@@ -25,7 +33,6 @@ import {
UserIcon,
XIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
import type { MessageDescriptor } from '@vintl/vintl'
import { defineMessages, useVIntl } from '@vintl/vintl'
import type { Component } from 'vue'
@@ -36,6 +43,7 @@ import { useRouter } from 'vue-router'
import { Tooltip } from 'floating-vue'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter()
@@ -255,7 +263,7 @@ const messages = defineMessages({
<template v-if="world.last_played">
{{
formatMessage(commonMessages.playedLabel, {
time: dayjs(world.last_played).fromNow(),
time: formatRelativeTime(dayjs(world.last_played).toISOString()),
})
}}
</template>

View File

@@ -54,7 +54,7 @@ async function saveServer() {
'server',
address.value,
newDisplayStatus.value,
)
).catch(handleError)
}
emit('submit', {

View File

@@ -41,6 +41,9 @@
"instance.edit-server.title": {
"message": "Edit server"
},
"instance.edit-world.hide-from-home": {
"message": "Hide from the Home page"
},
"instance.edit-world.name": {
"message": "Name"
},
@@ -362,6 +365,9 @@
"instance.worlds.copy_address": {
"message": "Copy address"
},
"instance.worlds.dont_show_on_home": {
"message": "Don't show on Home"
},
"instance.worlds.filter.available": {
"message": "Available"
},
@@ -377,6 +383,9 @@
"instance.worlds.play_anyway": {
"message": "Play anyway"
},
"instance.worlds.play_instance": {
"message": "Play instance"
},
"instance.worlds.type.server": {
"message": "Server"
},

View File

@@ -1,14 +1,11 @@
[package]
name = "theseus_playground"
version = "0.0.0"
edition = "2021"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
theseus = { path = "../../packages/app-lib", features = ["cli"] }
tokio = { version = "1", features = ["full"] }
webbrowser = "0.8.13"
enumset = "1.1"
tracing = "0.1.37"
theseus = { workspace = true, features = ["cli"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
enumset.workspace = true

View File

@@ -15,8 +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!("URL {}", login.redirect_uri.as_str());
webbrowser::open(login.redirect_uri.as_str())?;
println!("Open URL {} in a browser", login.redirect_uri.as_str());
println!("Please enter URL code: ");
let mut input = String::new();

View File

@@ -4,60 +4,49 @@ version = "0.9.5"
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/"
edition = "2021"
edition = "2024"
build = "build.rs"
[build-dependencies]
tauri-build = { version = "2.0.3", features = ["codegen"] }
tauri-build = { workspace = true, features = ["codegen"] }
[dependencies]
theseus = { path = "../../packages/app-lib", features = ["tauri"] }
theseus = { workspace = true, features = ["tauri"] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_with = "3.0.0"
serde_json.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_with.workspace = true
tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
tauri-plugin-window-state = "2.2.0"
tauri-plugin-deep-link = "2.2.0"
tauri-plugin-os = "2.2.0"
tauri-plugin-opener = "2.2.1"
tauri-plugin-dialog = "2.2.0"
tauri-plugin-updater = { version = "2.3.0" }
tauri-plugin-single-instance = { version = "2.2.0" }
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
tauri-plugin-window-state.workspace = true
tauri-plugin-deep-link.workspace = true
tauri-plugin-os.workspace = true
tauri-plugin-opener.workspace = true
tauri-plugin-dialog.workspace = true
tauri-plugin-updater.workspace = true
tauri-plugin-single-instance.workspace = true
tokio = { version = "1", features = ["full"] }
thiserror = "1.0"
daedalus = { path = "../../packages/daedalus" }
chrono = "0.4.26"
either = "1.15"
tokio = { workspace = true, features = ["time"] }
thiserror.workspace = true
daedalus.workspace = true
chrono.workspace = true
either.workspace = true
url = "2.2"
urlencoding = "2.1"
uuid = { version = "1.1", features = ["serde", "v4"] }
os_info = "3.7.0"
url.workspace = true
urlencoding.workspace = true
uuid = { workspace = true, features = ["serde", "v4"] }
tracing = "0.1.37"
tracing-error = "0.2.0"
tracing.workspace = true
tracing-error.workspace = true
dashmap = "6.0.1"
paste = "1.0.15"
enumset = { version = "1.1", features = ["serde"] }
dashmap.workspace = true
paste.workspace = true
enumset = { workspace = true, features = ["serde"] }
opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }
native-dialog = "0.7.0"
[target.'cfg(not(target_os = "linux"))'.dependencies]
window-shadows = "0.2.1"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"
objc = "0.2.7"
rand = "0.8.5"
native-dialog.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
tauri-plugin-updater = { version = "2.3.0", optional = true, features = ["native-tls-vendored", "zip"], default-features = false }
tauri-plugin-updater = { workspace = true, optional = true }
[features]
# by default Tauri runs in production mode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -9,7 +9,7 @@
"fix": "cargo fmt && cargo clippy --fix"
},
"devDependencies": {
"@tauri-apps/cli": "2.1.0"
"@tauri-apps/cli": "2.5.0"
},
"dependencies": {
"@modrinth/app-frontend": "workspace:*",

View File

@@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize};
use tauri::Runtime;
use tauri_plugin_opener::OpenerExt;
use theseus::{
handler,
prelude::{CommandPayload, DirectoryInfo},
@@ -47,57 +49,56 @@ pub enum OS {
// Create a new HashMap with the same keys
// Values provided should not be used directly, as they are not guaranteed to be up-to-date
#[tauri::command]
pub async fn progress_bars_list(
) -> Result<DashMap<uuid::Uuid, theseus::LoadingBar>> {
pub async fn progress_bars_list()
-> Result<DashMap<uuid::Uuid, theseus::LoadingBar>> {
let res = theseus::EventState::list_progress_bars().await?;
Ok(res)
}
// cfg only on mac os
// disables mouseover and fixes a random crash error only fixed by recent versions of macos
#[cfg(target_os = "macos")]
#[tauri::command]
pub async fn should_disable_mouseover() -> bool {
// We try to match version to 12.2 or higher. If unrecognizable to pattern or lower, we default to the css with disabled mouseover for safety
let os = os_info::get();
if let os_info::Version::Semantic(major, minor, _) = os.version() {
if *major >= 12 && *minor >= 3 {
// Mac os version is 12.3 or higher, we allow mouseover
return false;
if cfg!(target_os = "macos") {
// We try to match version to 12.2 or higher. If unrecognizable to pattern or lower, we default to the css with disabled mouseover for safety
if let tauri_plugin_os::Version::Semantic(major, minor, _) =
tauri_plugin_os::version()
{
if major >= 12 && minor >= 3 {
// Mac os version is 12.3 or higher, we allow mouseover
return false;
}
}
true
} else {
// Not macos, we allow mouseover
false
}
true
}
#[cfg(not(target_os = "macos"))]
#[tauri::command]
pub async fn should_disable_mouseover() -> bool {
false
}
#[tauri::command]
pub fn highlight_in_folder(path: PathBuf) {
let res = opener::reveal(path);
if let Err(e) = res {
pub fn highlight_in_folder<R: Runtime>(
app: tauri::AppHandle<R>,
path: PathBuf,
) {
if let Err(e) = app.opener().reveal_item_in_dir(path) {
tracing::error!("Failed to highlight file in folder: {}", e);
}
}
#[tauri::command]
pub fn open_path(path: PathBuf) {
let res = opener::open(path);
if let Err(e) = res {
pub fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) {
if let Err(e) = app.opener().open_path(path.to_string_lossy(), None::<&str>)
{
tracing::error!("Failed to open path: {}", e);
}
}
#[tauri::command]
pub fn show_launcher_logs_folder() {
pub fn show_launcher_logs_folder<R: Runtime>(app: tauri::AppHandle<R>) {
let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default();
// failure to get folder just opens filesystem
// (ie: if in debug mode only and launcher_logs never created)
open_path(path);
open_path(app, path);
}
// Get opening command

View File

@@ -3,7 +3,7 @@ use either::Either;
use enumset::EnumSet;
use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata;
use theseus::profile::{get_full_path, QuickPlayType};
use theseus::profile::{QuickPlayType, get_full_path};
use theseus::worlds::{
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
WorldWithProfile,

View File

@@ -1,2 +1 @@
pub mod deep_link;
pub mod window_ext;

View File

@@ -1,412 +0,0 @@
// Stolen from https://gist.github.com/charrondev/43150e940bd2771b1ea88256d491c7a9
use objc::{msg_send, sel, sel_impl};
use rand::{distributions::Alphanumeric, Rng};
use tauri::{
plugin::{Builder, TauriPlugin},
Emitter, Runtime, Window,
}; // 0.8
const WINDOW_CONTROL_PAD_X: f64 = 9.0;
const WINDOW_CONTROL_PAD_Y: f64 = 10.0;
struct UnsafeWindowHandle(*mut std::ffi::c_void);
unsafe impl Send for UnsafeWindowHandle {}
unsafe impl Sync for UnsafeWindowHandle {}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("traffic_light_positioner")
.on_window_ready(|window| {
#[cfg(target_os = "macos")]
setup_traffic_light_positioner(window);
})
.build()
}
#[cfg(target_os = "macos")]
fn position_traffic_lights(
ns_window_handle: UnsafeWindowHandle,
x: f64,
y: f64,
) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
use cocoa::foundation::NSRect;
let ns_window = ns_window_handle.0 as cocoa::base::id;
unsafe {
let close = ns_window
.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
let miniaturize = ns_window
.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
let zoom =
ns_window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
let title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height + 12.0;
let title_bar_frame_height = button_height + y;
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
title_bar_rect.origin.y =
NSView::frame(ns_window).size.height - title_bar_frame_height;
let _: () =
msg_send![title_bar_container_view, setFrame: title_bar_rect];
let window_buttons = vec![close, miniaturize, zoom];
let space_between =
NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
for (i, button) in window_buttons.into_iter().enumerate() {
let mut rect: NSRect = NSView::frame(button);
rect.origin.x = x + (i as f64 * space_between) + 6.0;
button.setFrameOrigin(rect.origin);
}
}
}
#[cfg(target_os = "macos")]
#[derive(Debug)]
struct WindowState<R: Runtime> {
window: Window<R>,
}
#[cfg(target_os = "macos")]
pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
use cocoa::appkit::NSWindow;
use cocoa::base::{id, BOOL};
use cocoa::foundation::NSUInteger;
use objc::runtime::{Object, Sel};
use std::ffi::c_void;
// Do the initial positioning
position_traffic_lights(
UnsafeWindowHandle(
window.ns_window().expect("Failed to create window handle"),
),
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
// Ensure they stay in place while resizing the window.
fn with_window_state<R: Runtime, F: FnOnce(&mut WindowState<R>) -> T, T>(
this: &Object,
func: F,
) {
let ptr = unsafe {
let x: *mut c_void = *this.get_ivar("app_box");
&mut *(x as *mut WindowState<R>)
};
func(ptr);
}
unsafe {
let ns_win = window
.ns_window()
.expect("NS Window should exist to mount traffic light delegate.")
as id;
let current_delegate: id = ns_win.delegate();
extern "C" fn on_window_should_close(
this: &Object,
_cmd: Sel,
sender: id,
) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, windowShouldClose: sender]
}
}
extern "C" fn on_window_will_close(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowWillClose: notification];
}
}
extern "C" fn on_window_did_resize<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
let id = state.window.ns_window().expect(
"NS window should exist on state to handle resize",
) as id;
#[cfg(target_os = "macos")]
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidResize: notification];
}
}
extern "C" fn on_window_did_move(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidMove: notification];
}
}
extern "C" fn on_window_did_change_backing_properties(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidChangeBackingProperties: notification];
}
}
extern "C" fn on_window_did_become_key(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () =
msg_send![super_del, windowDidBecomeKey: notification];
}
}
extern "C" fn on_window_did_resign_key(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () =
msg_send![super_del, windowDidResignKey: notification];
}
}
extern "C" fn on_dragging_entered(
this: &Object,
_cmd: Sel,
notification: id,
) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, draggingEntered: notification]
}
}
extern "C" fn on_prepare_for_drag_operation(
this: &Object,
_cmd: Sel,
notification: id,
) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, prepareForDragOperation: notification]
}
}
extern "C" fn on_perform_drag_operation(
this: &Object,
_cmd: Sel,
sender: id,
) -> BOOL {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, performDragOperation: sender]
}
}
extern "C" fn on_conclude_drag_operation(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () =
msg_send![super_del, concludeDragOperation: notification];
}
}
extern "C" fn on_dragging_exited(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, draggingExited: notification];
}
}
extern "C" fn on_window_will_use_full_screen_presentation_options(
this: &Object,
_cmd: Sel,
window: id,
proposed_options: NSUInteger,
) -> NSUInteger {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
msg_send![super_del, window: window willUseFullScreenPresentationOptions: proposed_options]
}
}
extern "C" fn on_window_did_enter_full_screen<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
state
.window
.emit("did-enter-fullscreen", ())
.expect("Failed to emit event");
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidEnterFullScreen: notification];
}
}
extern "C" fn on_window_will_enter_full_screen<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
state
.window
.emit("will-enter-fullscreen", ())
.expect("Failed to emit event");
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowWillEnterFullScreen: notification];
}
}
extern "C" fn on_window_did_exit_full_screen<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
state
.window
.emit("did-exit-fullscreen", ())
.expect("Failed to emit event");
let id =
state.window.ns_window().expect("Failed to emit event")
as id;
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () =
msg_send![super_del, windowDidExitFullScreen: notification];
}
}
extern "C" fn on_window_will_exit_full_screen<R: Runtime>(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
with_window_state(this, |state: &mut WindowState<R>| {
state
.window
.emit("will-exit-fullscreen", ())
.expect("Failed to emit event");
});
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowWillExitFullScreen: notification];
}
}
extern "C" fn on_window_did_fail_to_enter_full_screen(
this: &Object,
_cmd: Sel,
window: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, windowDidFailToEnterFullScreen: window];
}
}
extern "C" fn on_effective_appearance_did_change(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![super_del, effectiveAppearanceDidChange: notification];
}
}
extern "C" fn on_effective_appearance_did_changed_on_main_thread(
this: &Object,
_cmd: Sel,
notification: id,
) {
unsafe {
let super_del: id = *this.get_ivar("super_delegate");
let _: () = msg_send![
super_del,
effectiveAppearanceDidChangedOnMainThread: notification
];
}
}
// Are we deallocing this properly ? (I miss safe Rust :( )
let window_label = window.label().to_string();
let app_state = WindowState { window };
let app_box = Box::into_raw(Box::new(app_state)) as *mut c_void;
let random_str: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(20)
.map(char::from)
.collect();
// We need to ensure we have a unique delegate name, otherwise we will panic while trying to create a duplicate
// delegate with the same name.
let delegate_name =
format!("windowDelegate_{}_{}", window_label, random_str);
ns_win.setDelegate_(delegate!(&delegate_name, {
window: id = ns_win,
app_box: *mut c_void = app_box,
toolbar: id = cocoa::base::nil,
super_delegate: id = current_delegate,
(windowShouldClose:) => on_window_should_close as extern fn(&Object, Sel, id) -> BOOL,
(windowWillClose:) => on_window_will_close as extern fn(&Object, Sel, id),
(windowDidResize:) => on_window_did_resize::<R> as extern fn(&Object, Sel, id),
(windowDidMove:) => on_window_did_move as extern fn(&Object, Sel, id),
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern fn(&Object, Sel, id),
(windowDidBecomeKey:) => on_window_did_become_key as extern fn(&Object, Sel, id),
(windowDidResignKey:) => on_window_did_resign_key as extern fn(&Object, Sel, id),
(draggingEntered:) => on_dragging_entered as extern fn(&Object, Sel, id) -> BOOL,
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(performDragOperation:) => on_perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
(concludeDragOperation:) => on_conclude_drag_operation as extern fn(&Object, Sel, id),
(draggingExited:) => on_dragging_exited as extern fn(&Object, Sel, id),
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern fn(&Object, Sel, id),
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern fn(&Object, Sel, id),
(windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern fn(&Object, Sel, id),
(windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern fn(&Object, Sel, id),
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern fn(&Object, Sel, id),
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern fn(&Object, Sel, id)
}))
}
}

View File

@@ -3,7 +3,7 @@
windows_subsystem = "windows"
)]
use native_dialog::{MessageDialog, MessageType};
use native_dialog::{DialogBuilder, MessageLevel};
use std::env;
use tauri::{Listener, Manager};
use theseus::prelude::*;
@@ -14,14 +14,6 @@ mod error;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
#[macro_use]
extern crate cocoa;
#[cfg(target_os = "macos")]
#[macro_use]
extern crate objc;
// Should be called in launcher initialization
#[tracing::instrument(skip_all)]
#[tauri::command]
@@ -113,13 +105,14 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
fn show_window(app: tauri::AppHandle) {
let win = app.get_window("main").unwrap();
if let Err(e) = win.show() {
MessageDialog::new()
.set_type(MessageType::Error)
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Initialization error")
.set_text(&format!(
.set_text(format!(
"Cannot display application window due to an error:\n{e}"
))
.show_alert()
.alert()
.show()
.unwrap();
panic!("cannot display application window")
} else {
@@ -240,9 +233,9 @@ fn main() {
});
#[cfg(not(target_os = "linux"))]
{
if let Some(window) = app.get_window("main") {
window.set_shadow(true).unwrap();
if let Some(window) = app.get_window("main") {
if let Err(e) = window.set_shadow(true) {
tracing::warn!("Failed to set window shadow: {e}");
}
}
@@ -275,11 +268,6 @@ fn main() {
restart_app,
]);
#[cfg(target_os = "macos")]
{
builder = builder.plugin(macos::window_ext::init());
}
tracing::info!("Initializing app...");
let app = builder.build(tauri::generate_context!());
@@ -321,24 +309,26 @@ fn main() {
if format!("{e:?}").contains(
"Runtime(CreateWebview(WebView2Error(WindowsError",
) {
MessageDialog::new()
.set_type(MessageType::Error)
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Initialization error")
.set_text("Your Microsoft Edge WebView2 installation is corrupt.\n\nMicrosoft Edge WebView2 is required to run Modrinth App.\n\nLearn how to repair it at https://support.modrinth.com/en/articles/8797765-corrupted-microsoft-edge-webview2-installation")
.show_alert()
.alert()
.show()
.unwrap();
panic!("webview2 initialization failed")
}
}
MessageDialog::new()
.set_type(MessageType::Error)
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Initialization error")
.set_text(&format!(
.set_text(format!(
"Cannot initialize application due to an error:\n{e:?}"
))
.show_alert()
.alert()
.show()
.unwrap();
tracing::error!("Error while running tauri application: {:?}", e);

View File

@@ -1,4 +1,5 @@
{
"$schema": "https://schema.tauri.app/config/2",
"build": {
"beforeDevCommand": "pnpm turbo run dev --filter=@modrinth/app-frontend",
"beforeBuildCommand": "pnpm turbo run build --filter=@modrinth/app-frontend",
@@ -76,7 +77,14 @@
],
"security": {
"assetProtocol": {
"scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*", "$APPDATA/profiles/*/saves/*/icon.png", "$APPCONFIG/profiles/*/saves/*/icon.png", "$CONFIG/profiles/*/saves/*/icon.png"],
"scope": [
"$APPDATA/caches/icons/*",
"$APPCONFIG/caches/icons/*",
"$CONFIG/caches/icons/*",
"$APPDATA/profiles/*/saves/*/icon.png",
"$APPCONFIG/profiles/*/saves/*/icon.png",
"$CONFIG/profiles/*/saves/*/icon.png"
],
"enable": true
},
"capabilities": ["ads", "core", "plugins"],
@@ -87,7 +95,8 @@
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'"
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'",
"media-src": "https://*.githubusercontent.com"
}
}
}

View File

@@ -13,7 +13,11 @@
"minWidth": 1100,
"visible": false,
"zoomHotkeysEnabled": false,
"decorations": true
"decorations": true,
"trafficLightPosition": {
"x": 15.0,
"y": 22.0
}
}
]
}

View File

@@ -2,39 +2,29 @@
name = "daedalus_client"
version = "0.2.2"
authors = ["Jai A <jai@modrinth.com>"]
edition = "2021"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
daedalus = { path = "../../packages/daedalus" }
tokio = { version = "1", features = ["full"] }
futures = "0.3.25"
dotenvy = "0.15.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde-xml-rs = "0.6.0"
lazy_static = "1.4.0"
thiserror = "1.0"
reqwest = { version = "0.12.5", default-features = false, features = [
"stream",
"json",
"rustls-tls-native-roots",
] }
async_zip = { version = "0.0.17", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
bytes = "1.6.0"
rust-s3 = { version = "0.33.0", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
"reqwest",
] }
dashmap = "5.5.3"
sha1_smol = { version = "1.0.0", features = ["std"] }
indexmap = { version = "2.2.6", features = ["serde"] }
itertools = "0.13.0"
tracing-error = "0.2.0"
daedalus.workspace = true
tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] }
futures.workspace = true
dotenvy.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde-xml-rs.workspace = true
thiserror.workspace = true
reqwest = { workspace = true, features = ["stream", "json", "rustls-tls-native-roots"] }
async_zip = { workspace = true, features = ["chrono", "tokio-fs", "deflate", "bzip2", "zstd", "deflate64"] }
chrono = { workspace = true, features = ["serde"] }
bytes.workspace = true
rust-s3.workspace = true
dashmap.workspace = true
sha1_smol.workspace = true
indexmap = { workspace = true, features = ["serde"] }
itertools.workspace = true
tracing-error.workspace = true
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing.workspace = true
tracing-subscriber = { workspace = true, features = ["env-filter"] }

View File

@@ -1,4 +1,4 @@
FROM rust:1.82.0 as build
FROM rust:1.86.0 AS build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /usr/src/daedalus
@@ -9,9 +9,9 @@ RUN cargo build --release --package 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/*
&& apt-get install -y --no-install-recommends ca-certificates openssl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN update-ca-certificates

View File

@@ -12,7 +12,9 @@ pub enum ErrorKind {
SerdeJSON(#[from] serde_json::Error),
#[error("Error while deserializing XML: {0}")]
SerdeXML(#[from] serde_xml_rs::Error),
#[error("Failed to validate file checksum at url {url} with hash {hash} after {tries} tries")]
#[error(
"Failed to validate file checksum at url {url} with hash {hash} after {tries} tries"
)]
ChecksumFailure {
hash: String,
url: String,
@@ -22,7 +24,7 @@ pub enum ErrorKind {
Fetch { inner: reqwest::Error, item: String },
#[error("Error while uploading file to S3: {file}")]
S3 {
inner: s3::error::S3Error,
inner: Box<s3::error::S3Error>,
file: String,
},
#[error("Error acquiring semaphore: {0}")]

View File

@@ -1,6 +1,6 @@
use crate::util::{download_file, fetch_json, format_url};
use crate::{insert_mirrored_artifact, Error, MirrorArtifact, UploadFile};
use daedalus::modded::{Manifest, PartialVersionInfo, DUMMY_REPLACE_STRING};
use crate::{Error, MirrorArtifact, UploadFile, insert_mirrored_artifact};
use daedalus::modded::{DUMMY_REPLACE_STRING, Manifest, PartialVersionInfo};
use dashmap::DashMap;
use serde::Deserialize;
use std::sync::Arc;
@@ -169,10 +169,11 @@ async fn fetch(
insert_mirrored_artifact(
&new_name,
None,
vec![lib
.url
.clone()
.unwrap_or_else(|| maven_url.to_string())],
vec![
lib.url
.clone()
.unwrap_or_else(|| maven_url.to_string()),
],
false,
mirror_artifacts,
)?;

View File

@@ -1,5 +1,5 @@
use crate::util::{download_file, fetch_json, fetch_xml, format_url};
use crate::{insert_mirrored_artifact, Error, MirrorArtifact, UploadFile};
use crate::{Error, MirrorArtifact, UploadFile, insert_mirrored_artifact};
use chrono::{DateTime, Utc};
use daedalus::get_path_from_artifact;
use daedalus::modded::PartialVersionInfo;
@@ -7,8 +7,8 @@ use dashmap::DashMap;
use futures::io::Cursor;
use indexmap::IndexMap;
use itertools::Itertools;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Semaphore;
@@ -589,14 +589,16 @@ async fn fetch(
mod_loader: &str,
version: &ForgeVersion,
) -> Result<String, Error> {
let extract_file =
read_file(zip, &value[1..value.len()])
.await?
.ok_or_else(|| {
crate::ErrorKind::InvalidInput(format!(
let extract_file = read_file(
zip,
&value[1..value.len()],
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InvalidInput(format!(
"Unable reading data key {key} at path {value}",
))
})?;
})?;
let file_name = value.split('/').next_back()
.ok_or_else(|| {
@@ -622,10 +624,7 @@ async fn fetch(
let path = format!(
"com.modrinth.daedalus:{}-installer-extracts:{}:{}@{}",
mod_loader,
version.raw,
file_name,
ext
mod_loader, version.raw, file_name, ext
);
upload_files.insert(
@@ -753,7 +752,8 @@ async fn fetch(
.rev()
.chunk_by(|x| x.game_version.clone())
.into_iter()
.map(|(game_version, loaders)| daedalus::modded::Version {
.map(|(game_version, loaders)| {
daedalus::modded::Version {
id: game_version,
stable: true,
loaders: loaders
@@ -766,6 +766,7 @@ async fn fetch(
stable: false,
})
.collect(),
}
})
.collect(),
};

View File

@@ -1,13 +1,13 @@
use crate::util::{
format_url, upload_file_to_bucket, upload_url_to_bucket_mirrors,
REQWEST_CLIENT,
REQWEST_CLIENT, format_url, upload_file_to_bucket,
upload_url_to_bucket_mirrors,
};
use daedalus::get_path_from_artifact;
use dashmap::{DashMap, DashSet};
use std::sync::Arc;
use tokio::sync::Semaphore;
use tracing_error::ErrorLayer;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
mod error;
mod fabric;

View File

@@ -1,11 +1,11 @@
use crate::util::fetch_json;
use crate::{
util::download_file, util::format_url, util::sha1_async, Error,
MirrorArtifact, UploadFile,
Error, MirrorArtifact, UploadFile, util::download_file, util::format_url,
util::sha1_async,
};
use daedalus::minecraft::{
merge_partial_library, Library, PartialLibrary, VersionInfo,
VersionManifest, VERSION_MANIFEST_URL,
Library, PartialLibrary, VERSION_MANIFEST_URL, VersionInfo,
VersionManifest, merge_partial_library,
};
use dashmap::DashMap;
use serde::Deserialize;

View File

@@ -3,59 +3,57 @@ use bytes::Bytes;
use s3::creds::Credentials;
use s3::{Bucket, Region};
use serde::de::DeserializeOwned;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use tokio::sync::Semaphore;
lazy_static::lazy_static! {
static ref BUCKET : Bucket = {
let region = dotenvy::var("S3_REGION").unwrap();
let b = Bucket::new(
&dotenvy::var("S3_BUCKET_NAME").unwrap(),
if &*region == "r2" {
Region::R2 {
account_id: dotenvy::var("S3_URL").unwrap(),
}
} else {
Region::Custom {
region: region.clone(),
endpoint: dotenvy::var("S3_URL").unwrap(),
}
},
Credentials::new(
Some(&*dotenvy::var("S3_ACCESS_TOKEN").unwrap()),
Some(&*dotenvy::var("S3_SECRET").unwrap()),
None,
None,
None,
).unwrap(),
).unwrap();
if region == "path-style" {
b.with_path_style()
static BUCKET: LazyLock<Bucket> = LazyLock::new(|| {
let region = dotenvy::var("S3_REGION").unwrap();
let b = Bucket::new(
&dotenvy::var("S3_BUCKET_NAME").unwrap(),
if &*region == "r2" {
Region::R2 {
account_id: dotenvy::var("S3_URL").unwrap(),
}
} else {
b
}
};
}
Region::Custom {
region: region.clone(),
endpoint: dotenvy::var("S3_URL").unwrap(),
}
},
Credentials::new(
Some(&*dotenvy::var("S3_ACCESS_TOKEN").unwrap()),
Some(&*dotenvy::var("S3_SECRET").unwrap()),
None,
None,
None,
)
.unwrap(),
)
.unwrap();
lazy_static::lazy_static! {
pub static ref REQWEST_CLIENT: reqwest::Client = {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(header) = reqwest::header::HeaderValue::from_str(&format!(
"modrinth/daedalus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
)) {
headers.insert(reqwest::header::USER_AGENT, header);
}
if region == "path-style" {
*b.with_path_style()
} else {
*b
}
});
reqwest::Client::builder()
.tcp_keepalive(Some(std::time::Duration::from_secs(10)))
.timeout(std::time::Duration::from_secs(15))
.default_headers(headers)
.build()
.unwrap()
};
}
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(header) = reqwest::header::HeaderValue::from_str(&format!(
"modrinth/daedalus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
)) {
headers.insert(reqwest::header::USER_AGENT, header);
}
reqwest::Client::builder()
.tcp_keepalive(Some(std::time::Duration::from_secs(10)))
.timeout(std::time::Duration::from_secs(15))
.default_headers(headers)
.build()
.unwrap()
});
#[tracing::instrument(skip(bytes, semaphore))]
pub async fn upload_file_to_bucket(
@@ -78,7 +76,7 @@ pub async fn upload_file_to_bucket(
BUCKET.put_object(key.clone(), &bytes).await
}
.map_err(|err| ErrorKind::S3 {
inner: err,
inner: Box::new(err),
file: path.clone(),
});
@@ -203,7 +201,7 @@ pub async fn download_file(
inner: err,
item: url.to_string(),
}
.into())
.into());
}
}
}

View File

@@ -7,7 +7,7 @@ This project is part of our [monorepo](https://github.com/modrinth/code). You ca
[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
Now, you'll have to install the sqlx CLI, which can be done with cargo:
To get a basic configuration, copy the `.env.local` file to `.env`. Now, you'll have to install the sqlx CLI, which can be done with cargo:
```bash
cargo install --git https://github.com/launchbadge/sqlx sqlx-cli --no-default-features --features postgres,rustls
@@ -53,8 +53,12 @@ If you would like 'placeholder_category' to be marked as supporting modpacks too
INSERT INTO categories VALUES (0, 'placeholder_category', 2); -- modloader id, supported type id
```
You can find more example SQL statements for seeding the database in the `apps/labrinth/tests/files/dummy_data.sql` file.
The majority of configuration is done at runtime using [dotenvy](https://crates.io/crates/dotenvy) and the `.env` file. Each of the variables and what they do can be found in the dropdown below. Additionally, there are three command line options that can be used to specify to MeiliSearch what you want to do.
During development, you might notice that changes made directly to entities in the PostgreSQL database do not seem to take effect. This is often because the Redis cache still holds outdated data. To ensure your updates are reflected, clear the cache by e.g. running `redis-cli FLUSHALL`, which will force Labrinth to fetch the latest data from the database the next time it is needed.
<details>
<summary>.env variables & command line options</summary>
@@ -73,6 +77,11 @@ The majority of configuration is done at runtime using [dotenvy](https://crates.
`MEILISEARCH_KEY`: The name that MeiliSearch is given
`BIND_ADDR`: The bind address for the server. Supports both IPv4 and IPv6
`MOCK_FILE_PATH`: The path used to store uploaded files; this has no default value and will panic if unspecified
`SMTP_USERNAME`: The username used to authenticate with the SMTP server
`SMTP_PASSWORD`: The password associated with the `SMTP_USERNAME` for SMTP authentication
`SMTP_HOST`: The hostname or IP address of the SMTP server
`SMTP_PORT`: The port number on which the SMTP server is listening (commonly 25, 465, or 587)
`SMTP_TLS`: The TLS mode to use for the SMTP connection, which can be one of the following: `none`, `opportunistic_start_tls`, `requires_start_tls`, `tls`
#### CDN options

5
apps/frontend/.env.local Normal file
View File

@@ -0,0 +1,5 @@
BASE_URL=http://127.0.0.1:8000/v2/
BROWSER_BASE_URL=http://127.0.0.1:8000/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
PROD_OVERRIDE=true

View File

@@ -1,3 +1,4 @@
BASE_URL=https://api.modrinth.com/v2/
BROWSER_BASE_URL=https://api.modrinth.com/v2/
PYRO_BASE_URL=https://archon.modrinth.com/
PYRO_BASE_URL=https://archon.modrinth.com
PROD_OVERRIDE=true

View File

@@ -0,0 +1,4 @@
BASE_URL=https://staging-api.modrinth.com/v2/
BROWSER_BASE_URL=https://staging-api.modrinth.com/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
PROD_OVERRIDE=true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 734 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -162,6 +162,18 @@ html {
--landing-green-label-bg: rgba(0, 216, 69, 0.15);
--landing-raw-bg: #fff;
--banner-error-bg: #fee2e2;
--banner-error-text: #991b1b;
--banner-error-border: #ef4444;
--banner-warning-bg: #ffedd5;
--banner-warning-text: #713f12;
--banner-warning-border: #f97316;
--banner-info-bg: #dbeafe;
--banner-info-text: #1e3a8a;
--banner-info-border: #3b82f6;
}
.dark,
@@ -286,6 +298,18 @@ html {
--hover-filter: brightness(120%);
--active-filter: brightness(140%);
--banner-error-bg: #4c1515;
--banner-error-text: #fee2e2;
--banner-error-border: #7f1d1d;
--banner-warning-bg: #4a2a0a;
--banner-warning-text: #ffe6c0;
--banner-warning-border: #b54708;
--banner-info-bg: #1e2a44;
--banner-info-text: #dbeafe;
--banner-info-border: #2563eb;
}
.oled-mode {

View File

@@ -256,7 +256,9 @@
</p>
<div class="options input-group">
<button
v-for="(option, index) in steps[currentStepIndex].options"
v-for="(option, index) in steps[currentStepIndex].options.filter(
(x) => x.shown !== false,
)"
:key="index"
class="btn"
:class="{
@@ -426,6 +428,18 @@ const steps = computed(() =>
resultingMessage: `## Misuse of Title
Per section 5.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) we ask that you limit the title to just the name of your project. Additional information, such as themes, tags, supported versions or loaders, etc. should be saved for the Summary or Description. When changing your project title, remember to also ensure that your project slug (URL) matches and accurately represents your project.`,
},
{
name: "Minecraft title",
resultingMessage: `## Project Title
Projects must not use Minecraft's branding or include "Minecraft" as a significant part of the title.
The title of your project may be confusingly similar to the game, and we encourage you to change your title to avoid a potential violation of Minecraft's Usage Guidelines.
Abbreviations like "MC" or elaborate titles that do not make the name Minecraft a significant portion of the name are okay.`,
},
{
name: "Title similarities",
resultingMessage: `## Project Branding
Per section 1.8 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) we ask that you change your project title and other relevant branding to avoid causing confusion or implying association with existing projects.`,
},
],
},
{
@@ -472,6 +486,12 @@ Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
},
{
name: "Non-english",
resultingMessage: `## No English Summary
Per section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#accessibility) a project's Summary and Description must be in English, unless meant exclusively for non-English use, such as translations.
You may include your non-English Summary but we ask that you also add an English translation.`,
},
],
},
{
@@ -628,11 +648,21 @@ For a brief rundown of how this works:
{
id: "gallery",
navigate: `/${props.project.project_type}/${props.project.slug}/gallery`,
question: `Are the project's gallery images relevant?`,
shown: props.project.gallery.length > 0,
question: `Are this project's gallery images sufficient?`,
shown: true,
options: [
{
name: "Insufficient",
resultingMessage: `## Insufficient Gallery Images
We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations).
Keep in mind that you should:
- Set a featured image that best represents your project.
- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description.
- Upload any relevant images in your Description to your Gallery tab for best results.`,
},
{
name: "Not relevant",
shown: props.project.gallery.length > 0,
resultingMessage: `## Unrelated Gallery Images
Per section 5.5 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) any images in your project's Gallery must be relevant to the project and also include a Title.`,
},

View File

@@ -184,7 +184,7 @@
"
class="date"
>
{{ fromNow(notif.extra_data.version.date_published) }}
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
</span>
</span>
</div>
@@ -201,7 +201,7 @@
v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm A')"
class="inline-flex"
>
<CalendarIcon class="mr-1" /> Received {{ fromNow(notification.created) }}
<CalendarIcon class="mr-1" /> Received {{ formatRelativeTime(notification.created) }}
</span>
</span>
<div v-if="compact" class="notification__actions">
@@ -331,11 +331,12 @@ import {
XIcon,
ExternalIcon,
} from "@modrinth/assets";
import { useRelativeTime } from "@modrinth/ui";
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
import { getProjectLink, getVersionLink } from "~/helpers/projects.js";
import { getUserLink } from "~/helpers/users.js";
import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.js";
import { markAsRead } from "~/helpers/notifications.js";
import { markAsRead } from "~/helpers/notifications.ts";
import DoubleIcon from "~/components/ui/DoubleIcon.vue";
import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue";
@@ -345,6 +346,8 @@ import Categories from "~/components/ui/search/Categories.vue";
const app = useNuxtApp();
const emit = defineEmits(["update:notifications"]);
const formatRelativeTime = useRelativeTime();
const props = defineProps({
notification: {
type: Object,

View File

@@ -1,85 +1,140 @@
<template>
<div class="vue-notification-group">
<div class="vue-notification-group experimental-styles-within">
<transition-group name="notifs">
<div
v-for="(item, index) in notifications"
:key="item.id"
class="vue-notification-wrapper"
@click="notifications.splice(index, 1)"
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
<div class="notification-title" v-html="item.title"></div>
<div class="notification-content" v-html="item.text"></div>
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
<div
class="w-2"
:class="{
'bg-red': item.type === 'error',
'bg-orange': item.type === 'warning',
'bg-green': item.type === 'success',
'bg-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
}"
></div>
<div
class="grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1 py-2 pl-1 pr-3"
>
<div
class="flex items-center"
:class="{
'text-red': item.type === 'error',
'text-orange': item.type === 'warning',
'text-green': item.type === 'success',
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
}"
>
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-6 w-6" />
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
<InfoIcon v-else class="h-6 w-6" />
</div>
<div class="m-0 text-wrap font-bold text-contrast" v-html="item.title"></div>
<div class="flex items-center gap-1">
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
x{{ item.count }}
</div>
<ButtonStyled circular size="small">
<button v-tooltip="'Copy to clipboard'" @click="copyToClipboard(item)">
<CheckIcon v-if="copied[createNotifText(item)]" />
<CopyIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled circular size="small">
<button v-tooltip="`Dismiss`" @click="notifications.splice(index, 1)">
<XIcon />
</button>
</ButtonStyled>
</div>
<div></div>
<div class="col-span-2 text-sm text-primary" v-html="item.text"></div>
<template v-if="item.errorCode">
<div></div>
<div
class="m-0 text-wrap text-xs font-medium text-secondary"
v-html="item.errorCode"
></div>
</template>
</div>
</div>
</div>
</transition-group>
</div>
</template>
<script setup>
import { ButtonStyled } from "@modrinth/ui";
import {
XCircleIcon,
CheckCircleIcon,
CheckIcon,
InfoIcon,
IssuesIcon,
XIcon,
CopyIcon,
} from "@modrinth/assets";
const notifications = useNotifications();
function stopTimer(notif) {
clearTimeout(notif.timer);
}
const copied = ref({});
const createNotifText = (notif) => {
let text = "";
if (notif.title) {
text += notif.title;
}
if (notif.text) {
if (text.length > 0) {
text += "\n";
}
text += notif.text;
}
if (notif.errorCode) {
if (text.length > 0) {
text += "\n";
}
text += notif.errorCode;
}
return text;
};
function copyToClipboard(notif) {
const text = createNotifText(notif);
copied.value[text] = true;
navigator.clipboard.writeText(text);
setTimeout(() => {
delete copied.value[text];
}, 2000);
}
</script>
<style lang="scss" scoped>
.vue-notification {
background: var(--color-blue) !important;
border-left: 5px solid var(--color-blue) !important;
color: var(--color-brand-inverted) !important;
box-sizing: border-box;
text-align: left;
font-size: 12px;
padding: 10px;
margin: 0 5px 5px;
&.success {
background: var(--color-green) !important;
border-left-color: var(--color-green) !important;
}
&.warn {
background: var(--color-orange) !important;
border-left-color: var(--color-orange) !important;
}
&.error {
background: var(--color-red) !important;
border-left-color: var(--color-red) !important;
}
}
.vue-notification-group {
position: fixed;
right: 25px;
bottom: 25px;
z-index: 99999999;
width: 300px;
right: 1.5rem;
bottom: 1.5rem;
z-index: 200;
width: 450px;
@media screen and (max-width: 500px) {
width: calc(100% - 0.75rem * 2);
right: 0.75rem;
bottom: 0.75rem;
}
.vue-notification-wrapper {
width: 100%;
overflow: hidden;
margin-bottom: 10px;
.vue-notification-template {
border-radius: var(--size-rounded-card);
margin: 0;
.notification-title {
font-size: var(--font-size-lg);
margin-right: auto;
font-weight: 600;
}
.notification-content {
margin-right: auto;
font-size: var(--font-size-md);
}
}
&:last-child {
margin: 0;
}
@@ -98,10 +153,18 @@ function stopTimer(notif) {
.notifs-enter-active,
.notifs-leave-active,
.notifs-move {
transition: all 0.5s;
transition: all 0.25s ease-in-out;
}
.notifs-enter-from,
.notifs-leave-to {
opacity: 0;
}
.notifs-enter-from {
transform: translateY(100%) scale(0.8);
}
.notifs-leave-to {
transform: translateX(100%) scale(0.8);
}
</style>

View File

@@ -75,7 +75,7 @@
class="stat date"
>
<UpdatedIcon aria-hidden="true" />
<span class="date-label">Updated </span>{{ fromNow(updatedAt) }}
<span class="date-label">Updated </span>{{ formatRelativeTime(updatedAt) }}
</div>
<div
v-else-if="showCreatedDate"
@@ -83,7 +83,7 @@
class="stat date"
>
<CalendarIcon aria-hidden="true" />
<span class="date-label">Published </span>{{ fromNow(createdAt) }}
<span class="date-label">Published </span>{{ formatRelativeTime(createdAt) }}
</div>
</div>
</article>
@@ -95,6 +95,7 @@ import Categories from "~/components/ui/search/Categories.vue";
import Badge from "~/components/ui/Badge.vue";
import EnvironmentIndicator from "~/components/ui/EnvironmentIndicator.vue";
import Avatar from "~/components/ui/Avatar.vue";
import { useRelativeTime } from "@modrinth/ui";
export default {
components: {
@@ -213,8 +214,9 @@ export default {
},
setup() {
const tags = useTags();
const formatRelativeTime = useRelativeTime();
return { tags };
return { tags, formatRelativeTime };
},
computed: {
projectTypeDisplay() {

View File

@@ -256,11 +256,11 @@
>
<div class="country-flag-container">
<template v-if="name.toLowerCase() === 'xx' || !name">
<img
src="https://cdn.modrinth.com/placeholder-banner.svg"
alt="Placeholder flag"
class="country-flag"
/>
<div
class="country-flag flex select-none items-center justify-center bg-bg-raised font-extrabold text-secondary"
>
?
</div>
</template>
<template v-else>
<img
@@ -272,7 +272,7 @@
</div>
<div class="country-text">
<strong class="country-name">
<template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
<template v-if="name.toLowerCase() === 'xx' || !name">Other</template>
<template v-else>{{ countryCodeToName(name) }}</template>
</strong>
<span class="data-point">{{ formatNumber(count) }}</span>

View File

@@ -95,7 +95,7 @@
</nuxt-link>
<span>&nbsp;</span>
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
fromNow(report.created)
formatRelativeTime(report.created)
}}</span>
<CopyCode v-if="flags.developerMode" :text="report.id" class="report-id" />
</div>
@@ -105,11 +105,14 @@
<script setup>
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
import { renderHighlightedString } from "~/helpers/highlight.js";
import { useRelativeTime } from "@modrinth/ui";
import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue";
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
const formatRelativeTime = useRelativeTime();
defineProps({
report: {
type: Object,

View File

@@ -53,6 +53,7 @@
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #extract><PackageOpenIcon /> Extract</template>
<template #rename><EditIcon /> Rename</template>
<template #move><RightArrowIcon /> Move</template>
<template #download><DownloadIcon /> Download</template>
@@ -73,6 +74,8 @@ import {
FolderOpenIcon,
FileIcon,
RightArrowIcon,
PackageOpenIcon,
FileArchiveIcon,
} from "@modrinth/assets";
import { computed, shallowRef, ref } from "vue";
import { renderToString } from "vue/server-renderer";
@@ -99,15 +102,14 @@ interface FileItemProps {
const props = defineProps<FileItemProps>();
const emit = defineEmits<{
(e: "rename", item: { name: string; type: string; path: string }): void;
(e: "move", item: { name: string; type: string; path: string }): void;
(
e: "rename" | "move" | "download" | "delete" | "edit" | "extract",
item: { name: string; type: string; path: string },
): void;
(
e: "moveDirectTo",
item: { name: string; type: string; path: string; destination: string },
): void;
(e: "download", item: { name: string; type: string; path: string }): void;
(e: "delete", item: { name: string; type: string; path: string }): void;
(e: "edit", item: { name: string; type: string; path: string }): void;
(e: "contextmenu", x: number, y: number): void;
}>();
@@ -143,6 +145,7 @@ const codeExtensions = Object.freeze([
const textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
const supportedArchiveExtensions = Object.freeze(["zip"]);
const units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
const route = shallowRef(useRoute());
@@ -156,7 +159,18 @@ const containerClasses = computed(() => [
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
const isZip = computed(() => fileExtension.value === "zip");
const menuOptions = computed(() => [
{
id: "extract",
shown: isZip.value,
action: () => emit("extract", { name: props.name, type: props.type, path: props.path }),
},
{
divider: true,
shown: isZip.value,
},
{
id: "rename",
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
@@ -189,6 +203,7 @@ const iconComponent = computed(() => {
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon;
return FileIcon;
});

View File

@@ -30,6 +30,7 @@
:size="item.size"
@delete="$emit('delete', item)"
@rename="$emit('rename', item)"
@extract="$emit('extract', item)"
@download="$emit('download', item)"
@move="$emit('move', item)"
@move-direct-to="$emit('moveDirectTo', $event)"
@@ -49,14 +50,12 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(e: "delete", item: any): void;
(e: "rename", item: any): void;
(e: "download", item: any): void;
(e: "move", item: any): void;
(e: "edit", item: any): void;
(
e: "delete" | "rename" | "download" | "move" | "edit" | "moveDirectTo" | "extract",
item: any,
): void;
(e: "contextmenu", item: any, x: number, y: number): void;
(e: "loadMore"): void;
(e: "moveDirectTo", item: any): void;
}>();
const ITEM_HEIGHT = 61;

View File

@@ -117,7 +117,8 @@
</div>
<ButtonStyled type="transparent">
<UiServersTeleportOverflowMenu
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
position="bottom"
direction="left"
aria-label="Create new..."
@@ -125,6 +126,10 @@
{ id: 'file', action: () => $emit('create', 'file') },
{ id: 'directory', action: () => $emit('create', 'directory') },
{ id: 'upload', action: () => $emit('upload') },
{ divider: true },
{ id: 'upload-zip', shown: false, action: () => $emit('upload-zip') },
{ id: 'install-from-url', action: () => $emit('unzip-from-url', false) },
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
]"
>
<PlusIcon aria-hidden="true" />
@@ -132,7 +137,16 @@
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
</UiServersTeleportOverflowMenu>
<template #upload-zip>
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
</template>
<template #install-from-url>
<LinkIcon aria-hidden="true" /> Upload from .zip URL
</template>
<template #install-cf-pack>
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</header>
@@ -140,6 +154,9 @@
<script setup lang="ts">
import {
LinkIcon,
CurseForgeIcon,
FileArchiveIcon,
BoxIcon,
PlusIcon,
UploadIcon,
@@ -150,7 +167,7 @@ import {
ChevronRightIcon,
FilterIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ButtonStyled, OverflowMenu } from "@modrinth/ui";
import { ref, computed } from "vue";
import { useIntersectionObserver } from "@vueuse/core";
@@ -158,12 +175,14 @@ const props = defineProps<{
breadcrumbSegments: string[];
searchQuery: string;
currentFilter: string;
baseId: string;
}>();
defineEmits<{
(e: "navigate", index: number): void;
(e: "create", type: "file" | "directory"): void;
(e: "upload"): void;
(e: "upload" | "upload-zip"): void;
(e: "unzip-from-url", cf: boolean): void;
(e: "update:searchQuery", value: string): void;
(e: "filter", type: string): void;
}>();

View File

@@ -0,0 +1,56 @@
<template>
<ConfirmModal
ref="modal"
title="Do you want to overwrite these conflicting files?"
:proceed-label="`Overwrite`"
:proceed-icon="CheckIcon"
@proceed="proceed"
>
<div class="flex max-w-[30rem] flex-col gap-4">
<p class="m-0 font-semibold leading-normal">
<template v-if="hasMany">
Over 100 files will be overwritten if you proceed with extraction; here is just some of
them:
</template>
<template v-else>
The following {{ files.length }} files already exist on your server, and will be
overwritten if you proceed with extraction:
</template>
</p>
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
<XIcon class="shrink-0 text-red" /> {{ file }}
</li>
</ul>
</div>
</ConfirmModal>
</template>
<script setup lang="ts">
import { ConfirmModal } from "@modrinth/ui";
import { ref } from "vue";
import { XIcon, CheckIcon } from "@modrinth/assets";
const path = ref("");
const files = ref<string[]>([]);
const emit = defineEmits<{
(e: "proceed", path: string): void;
}>();
const modal = ref<typeof ConfirmModal>();
const hasMany = computed(() => files.value.length > 100);
const show = (zipPath: string, conflictingFiles: string[]) => {
path.value = zipPath;
files.value = conflictingFiles;
modal.value?.show();
};
const proceed = () => {
emit("proceed", path.value);
};
defineExpose({ show });
</script>

View File

@@ -1,101 +1,105 @@
<template>
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
<div
ref="statusContentRef"
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : "File" }} Uploads
<div>
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
<div
ref="statusContentRef"
v-bind="$attrs"
:class="['flex flex-col p-4 text-sm text-contrast']"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : "File" }} uploads
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
</span>
</div>
</div>
<div class="mt-2 space-y-2">
<div
v-for="item in uploadQueue"
:key="item.file.name"
class="flex h-6 items-center justify-between gap-2 text-xs"
>
<div class="flex flex-1 items-center gap-2 truncate">
<transition-group name="status-icon" mode="out-in">
<UiServersPanelSpinner
v-show="item.status === 'uploading'"
key="spinner"
class="absolute !size-4"
/>
<CheckCircleIcon
v-show="item.status === 'completed'"
key="check"
class="absolute size-4 text-green"
/>
<XCircleIcon
v-show="
item.status === 'error' ||
item.status === 'cancelled' ||
item.status === 'incorrect-type'
"
key="error"
class="absolute size-4 text-red"
/>
</transition-group>
<span class="ml-6 truncate">{{ item.file.name }}</span>
<span class="text-secondary">{{ item.size }}</span>
</div>
<div class="flex min-w-[80px] items-center justify-end gap-2">
<template v-if="item.status === 'completed'">
<span>Done</span>
</template>
<template v-else-if="item.status === 'error'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'incorrect-type'">
<span class="text-red">Failed - Incorrect file type</span>
</template>
<template v-else>
<template v-if="item.status === 'uploading'">
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
<button>Cancel</button>
</ButtonStyled>
</div>
<div class="mt-2 space-y-2">
<div
v-for="item in uploadQueue"
:key="item.file.name"
class="flex h-6 items-center justify-between gap-2 text-xs"
>
<div class="flex flex-1 items-center gap-2 truncate">
<transition-group name="status-icon" mode="out-in">
<UiServersPanelSpinner
v-show="item.status === 'uploading'"
key="spinner"
class="absolute !size-4"
/>
<CheckCircleIcon
v-show="item.status === 'completed'"
key="check"
class="absolute size-4 text-green"
/>
<XCircleIcon
v-show="
item.status === 'error' ||
item.status === 'cancelled' ||
item.status === 'incorrect-type'
"
key="error"
class="absolute size-4 text-red"
/>
</transition-group>
<span class="ml-6 truncate">{{ item.file.name }}</span>
<span class="text-secondary">{{ item.size }}</span>
</div>
<div class="flex min-w-[80px] items-center justify-end gap-2">
<template v-if="item.status === 'completed'">
<span>Done</span>
</template>
<template v-else-if="item.status === 'cancelled'">
<span class="text-red">Cancelled</span>
<template v-else-if="item.status === 'error'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'incorrect-type'">
<span class="text-red">Failed - Incorrect file type</span>
</template>
<template v-else>
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<template v-if="item.status === 'uploading'">
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
<button>Cancel</button>
</ButtonStyled>
</template>
<template v-else-if="item.status === 'cancelled'">
<span class="text-red">Cancelled</span>
</template>
<template v-else>
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
</template>
</template>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Transition>
</div>
</template>
<script setup lang="ts">
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, nextTick } from "vue";
import type { FSModule } from "~/composables/pyroServers.ts";
interface UploadItem {
file: File;

View File

@@ -0,0 +1,159 @@
<template>
<NewModal
ref="modal"
:header="cf ? `Installing a CurseForge pack` : `Uploading .zip contents from URL`"
>
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-bold text-contrast">
{{ cf ? `How to get the modpack version's URL` : "URL of .zip file" }}
</div>
<ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary">
<li>
<a
href="https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks"
class="inline-flex font-semibold text-[#F16436] transition-all hover:underline active:brightness-[--hover-brightness]"
target="_blank"
rel="noopener noreferrer"
>
Find the CurseForge modpack
<ExternalIcon class="ml-1 inline size-4" stroke-width="3" />
</a>
you'd like to install on your server.
</li>
<li>
On the modpack's page, go to the
<span class="font-semibold text-primary">"Files"</span> tab, and
<span class="font-semibold text-primary">select the version</span> of the modpack you
want to install.
</li>
<li>
<span class="font-semibold text-primary">Copy the URL</span> of the version you want to
install, and paste it in the box below.
</li>
</ol>
<p v-else class="mb-1 mt-0">Copy and paste the direct download URL of a .zip file.</p>
<input
ref="urlInput"
v-model="url"
autofocus
:disabled="submitted"
type="text"
data-1p-ignore
data-lpignore="true"
data-protonpass-ignore="true"
required
:placeholder="
cf
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
"
autocomplete="off"
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server.serverId}/backups`" />
<div class="flex justify-start gap-2">
<ButtonStyled color="brand">
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
<SpinnerIcon v-if="submitted" class="animate-spin" />
<DownloadIcon v-else class="h-5 w-5" />
{{ submitted ? "Installing..." : "Install" }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
{{ submitted ? "Close" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { ExternalIcon, SpinnerIcon, DownloadIcon, XIcon } from "@modrinth/assets";
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue";
import { handleError, type Server } from "~/composables/pyroServers.ts";
const cf = ref(false);
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const modal = ref<typeof NewModal>();
const urlInput = ref<HTMLInputElement | null>(null);
const url = ref("");
const submitted = ref(false);
const trimmedUrl = computed(() => url.value.trim());
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/;
const error = computed(() => {
if (trimmedUrl.value.length === 0) {
return "URL is required.";
}
if (cf.value && !regex.test(trimmedUrl.value)) {
return "URL must be a CurseForge modpack version URL.";
} else if (!cf.value && !trimmedUrl.value.includes("/")) {
return "URL must be valid.";
}
return "";
});
const handleSubmit = async () => {
submitted.value = true;
if (!error.value) {
// hide();
try {
const dry = await props.server.fs?.extractFile(trimmedUrl.value, true, true);
if (!cf.value || dry.modpack_name) {
await props.server.fs?.extractFile(trimmedUrl.value, true, false, true);
hide();
} else {
submitted.value = false;
handleError(
new ServersError(
"Could not find CurseForge modpack at that URL.",
undefined,
undefined,
undefined,
{
context: "Error installing modpack",
error: `url: ${url.value}`,
description: "Could not find CurseForge modpack at that URL.",
},
),
);
}
} catch (error) {
submitted.value = false;
console.error("Error installing:", error);
handleError(error);
}
}
};
const show = (isCf: boolean) => {
cf.value = isCf;
url.value = "";
submitted.value = false;
modal.value?.show();
nextTick(() => {
setTimeout(() => {
urlInput.value?.focus();
}, 100);
});
};
const hide = () => {
modal.value?.hide();
};
defineExpose({ show, hide });
</script>

View File

@@ -60,15 +60,7 @@
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<div
v-if="status === 'suspended' && suspension_reason === 'support'"
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"
>
<HammerIcon />
You recently requested support for your server and we are actively working on it. It will be
back online shortly.
</div>
<div
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
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"
>
<div class="flex flex-row gap-2">

View File

@@ -32,68 +32,68 @@
@mousedown.stop
@mouseleave="handleMouseLeave"
>
<ButtonStyled
<template
v-for="(option, index) in filteredOptions"
:key="option.id"
type="transparent"
role="menuitem"
:color="option.color"
:key="isDivider(option) ? `divider-${index}` : option.id"
>
<button
v-if="typeof option.action === 'function'"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</button>
<nuxt-link
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:to="option.action"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</nuxt-link>
<a
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:href="option.action"
target="_blank"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</a>
<span v-else>
<slot :name="option.id">{{ option.id }}</slot>
</span>
</ButtonStyled>
<div v-if="isDivider(option)" class="h-px w-full bg-button-bg"></div>
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
<button
v-if="typeof option.action === 'function'"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</button>
<nuxt-link
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:to="option.action"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</nuxt-link>
<a
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:href="option.action"
target="_blank"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</a>
<span v-else>
<slot :name="option.id">{{ option.id }}</slot>
</span>
</ButtonStyled>
</template>
</div>
</Transition>
</Teleport>
@@ -112,9 +112,20 @@ interface Option {
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
}
type Divider = {
divider: true;
shown?: boolean;
};
type Item = Option | Divider;
function isDivider(item: Item): item is Divider {
return (item as Divider).divider;
}
const props = withDefaults(
defineProps<{
options: Option[];
options: Item[];
hoverable?: boolean;
}>(),
{
@@ -338,7 +349,9 @@ const handleKeydown = (event: KeyboardEvent) => {
case " ":
event.preventDefault();
if (selectedIndex.value >= 0) {
selectOption(filteredOptions.value[selectedIndex.value]);
const option = filteredOptions.value[selectedIndex.value];
if (isDivider(option)) break;
selectOption(option);
}
break;
case "Escape":
@@ -361,8 +374,9 @@ const handleKeydown = (event: KeyboardEvent) => {
default:
if (event.key.length === 1) {
typeAheadBuffer.value += event.key.toLowerCase();
const matchIndex = filteredOptions.value.findIndex((option) =>
option.id.toLowerCase().startsWith(typeAheadBuffer.value),
const matchIndex = filteredOptions.value.findIndex(
(option) =>
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
);
if (matchIndex !== -1) {
selectedIndex.value = matchIndex;

View File

@@ -3,6 +3,7 @@ import dayjs from "dayjs";
import { ButtonStyled, commonMessages, CopyCode, ServerNotice, TagItem } from "@modrinth/ui";
import { EditIcon, SettingsIcon, TrashIcon } from "@modrinth/assets";
import { ServerNotice as ServerNoticeType } from "@modrinth/utils";
import { useRelativeTime } from "@modrinth/ui";
import {
DISMISSABLE,
getDismissableMetadata,
@@ -11,6 +12,7 @@ import {
import { useVIntl } from "@vintl/vintl";
const { formatMessage } = useVIntl();
const formatRelativeTime = useRelativeTime();
const props = defineProps<{
notice: ServerNoticeType;
@@ -25,7 +27,7 @@ const props = defineProps<{
<div class="text-sm">
<span v-if="notice.announce_at">
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
dayjs(notice.announce_at).fromNow()
formatRelativeTime(notice.announce_at)
}})
</span>
<template v-else> Never begins </template>
@@ -35,7 +37,7 @@ const props = defineProps<{
v-if="notice.expires"
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
>
{{ dayjs(notice.expires).fromNow() }}
{{ formatRelativeTime(notice.expires) }}
</span>
<template v-else> Never expires </template>
</div>

View File

@@ -103,7 +103,7 @@ import {
ModrinthIcon,
ScaleIcon,
} from "@modrinth/assets";
import { AutoLink, OverflowMenu } from "@modrinth/ui";
import { AutoLink, OverflowMenu, useRelativeTime } from "@modrinth/ui";
import { renderString } from "@modrinth/utils";
import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue";

View File

@@ -1,6 +0,0 @@
export const useUserCountry = () =>
useState("userCountry", () => {
const headers = useRequestHeaders(["cf-ipcountry"]);
return headers["cf-ipcountry"] ?? "US";
});

View File

@@ -0,0 +1,36 @@
import { useState, useRequestHeaders } from "#imports";
export const useUserCountry = () => {
const country = useState<string>("userCountry", () => "US");
const fromServer = useState<boolean>("userCountryFromServer", () => false);
if (import.meta.server) {
const headers = useRequestHeaders(["cf-ipcountry", "accept-language"]);
const cf = headers["cf-ipcountry"];
if (cf) {
country.value = cf.toUpperCase();
fromServer.value = true;
} else {
const al = headers["accept-language"] || "";
const tag = al.split(",")[0];
const val = tag.split("-")[1]?.toLowerCase();
if (val) {
country.value = val;
fromServer.value = true;
}
}
}
if (import.meta.client) {
onMounted(() => {
if (fromServer.value) return;
const lang = navigator.language || navigator.userLanguage || "";
const region = lang.split("-")[1];
if (region) {
country.value = region.toUpperCase();
}
});
}
return country;
};

View File

@@ -1,17 +0,0 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime); // eslint-disable-line import/no-named-as-default-member
export const useCurrentDate = () => useState("currentDate", () => Date.now());
export const updateCurrentDate = () => {
const currentDate = useCurrentDate();
currentDate.value = Date.now();
};
export const fromNow = (date) => {
const currentDate = useCurrentDate();
return dayjs(date).from(currentDate.value);
};

View File

@@ -1,18 +0,0 @@
import { createFormatter, type Formatter } from "@vintl/how-ago";
import type { IntlController } from "@vintl/vintl/controller";
const formatters = new WeakMap<IntlController<any>, Formatter>();
export function useRelativeTime(): Formatter {
const vintl = useVIntl();
let formatter = formatters.get(vintl);
if (formatter == null) {
const formatterRef = computed(() => createFormatter(vintl.intl));
formatter = (value, options) => formatterRef.value(value, options);
formatters.set(vintl, formatter);
}
return formatter;
}

View File

@@ -11,11 +11,13 @@ export const addNotification = (notification) => {
);
if (existingNotif) {
setNotificationTimer(existingNotif);
existingNotif.count++;
return;
}
notification.id = new Date();
notification.count = 1;
setNotificationTimer(notification);
notifications.value.push(notification);

View File

@@ -1,7 +1,7 @@
// usePyroServer is a composable that interfaces with the REDACTED API to get data and control the users server
import { $fetch, FetchError } from "ofetch";
import type { ServerNotice } from "@modrinth/utils";
import type { WSBackupState, WSBackupTask } from "~/types/servers.ts";
import type { FilesystemOp, FSQueuedOp, WSBackupState, WSBackupTask } from "~/types/servers.ts";
interface PyroFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -40,12 +40,19 @@ class PyroServerError extends Error {
}
}
export class PyroServersFetchError extends Error {
type V1ErrorInfo = {
context?: string;
error: string;
description: string;
};
export class ServersError extends Error {
constructor(
message: string,
public readonly statusCode?: number,
public readonly originalError?: Error,
public readonly module?: string,
public readonly v1Error?: V1ErrorInfo,
) {
let errorMessage = message;
let method = "GET";
@@ -96,17 +103,35 @@ export class PyroServersFetchError extends Error {
}
}
export const handleError = (err: any) => {
if (err instanceof ServersError && err.v1Error) {
addNotification({
title: err.v1Error?.context ?? `An error occurred`,
type: "error",
text: err.v1Error.description,
errorCode: err.v1Error.error,
});
} else {
addNotification({
title: "An error occurred",
type: "error",
text: err.message ?? (err.data ? err.data.description : err),
});
}
};
async function PyroFetch<T>(
path: string,
options: PyroFetchOptions = {},
module?: string,
errorContext?: string,
): Promise<T> {
const config = useRuntimeConfig();
const auth = await useAuth();
const authToken = auth.value?.token;
if (!authToken) {
throw new PyroServersFetchError("Missing auth token", 401, undefined, module);
throw new ServersError("Missing auth token", 401, undefined, module);
}
const {
@@ -124,16 +149,18 @@ async function PyroFetch<T>(
);
if (!base) {
throw new PyroServersFetchError(
"Configuration error: Missing PYRO_BASE_URL",
500,
undefined,
module,
);
throw new ServersError("Configuration error: Missing PYRO_BASE_URL", 500, undefined, module);
}
const fullUrl = override?.url
? `https://${override.url}/${path.replace(/^\//, "")}`
const versionString = `v${version}`;
let newOverrideUrl = override?.url;
if (newOverrideUrl && newOverrideUrl.includes("v0") && version !== 0) {
newOverrideUrl = newOverrideUrl.replace("v0", versionString);
}
const fullUrl = newOverrideUrl
? `https://${newOverrideUrl}/${path.replace(/^\//, "")}`
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
const headers: Record<string, string> = {
@@ -170,11 +197,20 @@ async function PyroFetch<T>(
attempts++;
if (error instanceof FetchError) {
let v1Error: V1ErrorInfo | undefined;
if (error.data.error && error.data.description) {
v1Error = {
context: errorContext,
...error.data,
};
}
const statusCode = error.response?.status;
const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
if (!isRetryable || attempts >= maxAttempts) {
throw new PyroServersFetchError(error.message, statusCode, error, module);
throw new ServersError(error.message, statusCode, error, module, v1Error);
}
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
@@ -182,7 +218,7 @@ async function PyroFetch<T>(
continue;
}
throw new PyroServersFetchError(
throw new ServersError(
"Unexpected error during fetch operation",
undefined,
error as Error,
@@ -271,10 +307,8 @@ interface General {
| "moderated"
| "paymentfailed"
| "cancelled"
| "other"
| "transferring"
| "upgrading"
| "support"
| "other"
| (string & {});
loader: string;
loader_version: string;
@@ -419,7 +453,7 @@ const processImage = async (iconUrl: string | undefined) => {
}
}
} catch (error) {
if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
if (error instanceof ServersError && error.statusCode === 404 && iconUrl) {
try {
const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
@@ -892,7 +926,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
try {
return await requestFn();
} catch (error) {
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
if (error instanceof ServersError && error.statusCode === 401) {
await internalServerReference.value.refresh(["fs"]);
return await requestFn();
}
@@ -1051,6 +1085,68 @@ const moveFileOrFolder = (path: string, newPath: string) => {
});
};
const clearQueuedOps = () => {
internalServerReference.value.fs.queuedOps = [];
};
const removeQueuedOp = (op: FSQueuedOp["op"], src: string) => {
internalServerReference.value.fs.queuedOps = internalServerReference.value.fs.queuedOps.filter(
(x: FSQueuedOp) => x.op !== op || x.src !== src,
);
};
const extractFile = (path: string, override = true, dry = false, silentQueue = false) =>
retryWithAuth(async () => {
console.log(
`Extracting: ${path}` + (dry ? " (dry run)" : "") + (silentQueue ? " (silent)" : ""),
);
const encodedPath = encodeURIComponent(path);
if (!silentQueue) {
internalServerReference.value.fs.queuedOps.push({
op: "unarchive",
src: path,
});
setTimeout(() => internalServerReference.value.fs.removeQueuedOp("unarchive", path), 4000);
}
return (await PyroFetch(
`/unarchive?src=${encodedPath}&trg=/&override=${override}&dry=${dry}`,
{
method: "POST",
override: internalServerReference.value.fs.auth,
version: 1,
},
undefined,
"Error extracting file",
).catch((err) => {
removeQueuedOp("unarchive", path);
throw err;
})) as { modpack_name: string | null };
});
const modifyOp = (id: string, action: "dismiss" | "cancel") =>
retryWithAuth(async () => {
return await PyroFetch(
`/ops/${action}?id=${id}`,
{
method: "POST",
override: internalServerReference.value.fs.auth,
version: 1,
},
undefined,
`Error ${action === "dismiss" ? "dismissing" : "cancelling"} filesystem operation`,
).then(() => {
internalServerReference.value.fs.opsQueuedForModification =
internalServerReference.value.fs.opsQueuedForModification.filter((x: string) => x !== id);
internalServerReference.value.fs.ops = internalServerReference.value.fs.ops.filter(
(x: FilesystemOp) => x.id !== id,
);
});
});
const deleteFileOrFolder = (path: string, recursive: boolean) => {
const encodedPath = encodeURIComponent(path);
return retryWithAuth(async () => {
@@ -1104,9 +1200,9 @@ const modules: any = {
return data;
} catch (error) {
const fetchError =
error instanceof PyroServersFetchError
error instanceof ServersError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
: new ServersError("Unknown error occurred", undefined, error as Error);
return {
status: "error",
@@ -1135,9 +1231,9 @@ const modules: any = {
};
} catch (error) {
const fetchError =
error instanceof PyroServersFetchError
error instanceof ServersError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
: new ServersError("Unknown error occurred", undefined, error as Error);
return {
data: [],
@@ -1160,9 +1256,9 @@ const modules: any = {
};
} catch (error) {
const fetchError =
error instanceof PyroServersFetchError
error instanceof ServersError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
: new ServersError("Unknown error occurred", undefined, error as Error);
return {
data: [],
@@ -1196,9 +1292,9 @@ const modules: any = {
};
} catch (error) {
const fetchError =
error instanceof PyroServersFetchError
error instanceof ServersError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
: new ServersError("Unknown error occurred", undefined, error as Error);
return {
allocations: [],
@@ -1221,9 +1317,9 @@ const modules: any = {
return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
} catch (error) {
const fetchError =
error instanceof PyroServersFetchError
error instanceof ServersError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
: new ServersError("Unknown error occurred", undefined, error as Error);
return {
error: {
@@ -1241,9 +1337,9 @@ const modules: any = {
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
} catch (error) {
const fetchError =
error instanceof PyroServersFetchError
error instanceof ServersError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
: new ServersError("Unknown error occurred", undefined, error as Error);
return {
error: {
@@ -1255,14 +1351,16 @@ const modules: any = {
},
},
fs: {
queuedOps: [],
opsQueuedForModification: [],
get: async (serverId: string) => {
try {
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
} catch (error) {
const fetchError =
error instanceof PyroServersFetchError
error instanceof ServersError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
: new ServersError("Unknown error occurred", undefined, error as Error);
return {
auth: undefined,
@@ -1281,6 +1379,10 @@ const modules: any = {
moveFileOrFolder,
deleteFileOrFolder,
downloadFile,
extractFile,
removeQueuedOp,
clearQueuedOps,
modifyOp,
},
};
@@ -1588,10 +1690,29 @@ type FSFunctions = {
* @returns
*/
downloadFile: (path: string, raw?: boolean) => Promise<any>;
/**
* @param path - The path of the file to extract
* @returns
*/
extractFile: (
path: string,
override?: boolean,
dry?: boolean,
silentQueue?: boolean,
) => Promise<{
modpack_name: string | null;
conflicting_files: string[];
}>;
removeQueuedOp: (op: FSQueuedOp["op"], src: string) => void;
clearQueuedOps: () => void;
modifyOp: (id: string, action: "dismiss" | "cancel") => Promise<any>;
};
type ModuleError = {
error: PyroServersFetchError;
error: ServersError;
timestamp: number;
};
@@ -1624,8 +1745,11 @@ type WSModule = JWTAuth & {
error?: ModuleError;
};
type FSModule = {
export type FSModule = {
auth: JWTAuth;
ops: FilesystemOp[];
queuedOps: FSQueuedOp[];
opsQueuedForModification: string[];
error?: ModuleError;
} & FSFunctions;

View File

@@ -5,12 +5,7 @@
<Logo404 />
</div>
<div class="error-box" :class="{ 'has-bot': !is404 }">
<img
v-if="!is404"
src="https://cdn-raw.modrinth.com/sad-bot.webp"
alt="Sad Modrinth bot"
class="error-box__sad-bot"
/>
<img v-if="!is404" :src="SadRinthbot" alt="Sad Modrinth bot" class="error-box__sad-bot" />
<div v-if="!is404" class="error-box__top-glow" />
<div class="error-box__body">
<h1 class="error-box__title">{{ formatMessage(errorMessages.title) }}</h1>
@@ -55,6 +50,7 @@
<script setup>
import { defineMessage, useVIntl } from "@vintl/vintl";
import { SadRinthbot } from "@modrinth/assets";
import Logo404 from "~/assets/images/404.svg";
const { formatMessage } = useVIntl();
@@ -272,6 +268,19 @@ const routeMessages = [
}
}
.error-graphic {
margin-bottom: 2rem;
display: flex;
justify-content: center;
svg {
fill: var(--color-text);
color: var(--color-text);
width: min(15rem, 100%);
height: auto;
}
}
.error-box {
background-color: var(--color-raised-bg);
border-radius: 1.25rem;
@@ -281,105 +290,96 @@ const routeMessages = [
gap: 1.25rem;
box-shadow: var(--shadow-card);
position: relative;
}
.error-box.has-bot {
margin-block: 120px;
}
&.has-bot {
margin-block: 120px;
}
.error-box p {
margin: 0;
}
p {
margin: 0;
}
.error-box a {
color: var(--color-brand);
font-weight: 600;
}
a {
color: var(--color-brand);
font-weight: 600;
.error-box a:hover,
.error-box a:focus {
filter: brightness(1.125);
text-decoration: underline;
}
&:hover,
&:focus {
filter: brightness(1.125);
text-decoration: underline;
}
}
.error-graphic {
margin-bottom: 2rem;
display: flex;
justify-content: center;
}
&__sad-bot {
--_bot-height: 112px;
position: absolute;
top: calc(-1 * var(--_bot-height));
right: 5rem;
width: auto;
height: var(--_bot-height);
.error-graphic svg {
fill: var(--color-text);
color: var(--color-text);
@media screen and (max-width: 768px) {
--_bot-height: 70px;
right: 2rem;
}
}
width: min(15rem, 100%);
height: auto;
}
&__top-glow {
width: 100%;
height: 1px;
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
position: absolute;
top: 0;
left: 0;
opacity: 0.4;
}
.error-box__sad-bot {
--_bot-height: 112px;
position: absolute;
top: calc(-1 * var(--_bot-height));
right: 5rem;
width: auto;
height: var(--_bot-height);
}
&__title {
font-size: 2rem;
font-weight: 900;
margin: 0;
}
.error-box__top-glow {
width: 100%;
height: 1px;
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
position: absolute;
top: 0;
left: 0;
opacity: 0.4;
}
&__subtitle {
font-size: 1.25rem;
font-weight: 600;
}
.error-box__title {
font-size: 2rem;
font-weight: 900;
margin: 0;
}
&__body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.error-box__subtitle {
font-size: 1.25rem;
font-weight: 600;
}
&__list-title {
font-weight: 600;
}
.error-box__body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__list {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-left: 1.25rem;
}
.error-box__list-title {
font-weight: 600;
}
li {
line-height: 1.5;
}
.error-box__list {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-left: 1.25rem;
}
.error-box li {
line-height: 1.5;
}
.error-box__details {
display: flex;
flex-direction: column;
color: var(--color-secondary);
gap: 0.25rem;
font-weight: 500;
font-size: 0.875rem;
&__details {
display: flex;
flex-direction: column;
color: var(--color-secondary);
gap: 0.25rem;
font-weight: 500;
font-size: 0.875rem;
}
}
</style>

View File

@@ -1,170 +0,0 @@
import { useNuxtApp } from "#imports";
async function getBulk(type, ids, apiVersion = 2) {
if (ids.length === 0) {
return [];
}
const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`;
return await useBaseFetch(url, { apiVersion });
}
export async function fetchExtraNotificationData(notifications) {
const bulk = {
projects: [],
reports: [],
threads: [],
users: [],
versions: [],
organizations: [],
};
for (const notification of notifications) {
if (notification.body) {
if (notification.body.project_id) {
bulk.projects.push(notification.body.project_id);
}
if (notification.body.version_id) {
bulk.versions.push(notification.body.version_id);
}
if (notification.body.report_id) {
bulk.reports.push(notification.body.report_id);
}
if (notification.body.thread_id) {
bulk.threads.push(notification.body.thread_id);
}
if (notification.body.invited_by) {
bulk.users.push(notification.body.invited_by);
}
if (notification.body.organization_id) {
bulk.organizations.push(notification.body.organization_id);
}
}
}
const reports = await getBulk("reports", bulk.reports);
for (const report of reports) {
if (report.item_type === "project") {
bulk.projects.push(report.item_id);
} else if (report.item_type === "user") {
bulk.users.push(report.item_id);
} else if (report.item_type === "version") {
bulk.versions.push(report.item_id);
}
}
const versions = await getBulk("versions", bulk.versions);
for (const version of versions) {
bulk.projects.push(version.project_id);
}
const [projects, threads, users, organizations] = await Promise.all([
getBulk("projects", bulk.projects),
getBulk("threads", bulk.threads),
getBulk("users", bulk.users),
getBulk("organizations", bulk.organizations, 3),
]);
for (const notification of notifications) {
notification.extra_data = {};
if (notification.body) {
if (notification.body.project_id) {
notification.extra_data.project = projects.find(
(x) => x.id === notification.body.project_id,
);
}
if (notification.body.organization_id) {
notification.extra_data.organization = organizations.find(
(x) => x.id === notification.body.organization_id,
);
}
if (notification.body.report_id) {
notification.extra_data.report = reports.find((x) => x.id === notification.body.report_id);
const type = notification.extra_data.report.item_type;
if (type === "project") {
notification.extra_data.project = projects.find(
(x) => x.id === notification.extra_data.report.item_id,
);
} else if (type === "user") {
notification.extra_data.user = users.find(
(x) => x.id === notification.extra_data.report.item_id,
);
} else if (type === "version") {
notification.extra_data.version = versions.find(
(x) => x.id === notification.extra_data.report.item_id,
);
notification.extra_data.project = projects.find(
(x) => x.id === notification.extra_data.version.project_id,
);
}
}
if (notification.body.thread_id) {
notification.extra_data.thread = threads.find((x) => x.id === notification.body.thread_id);
}
if (notification.body.invited_by) {
notification.extra_data.invited_by = users.find(
(x) => x.id === notification.body.invited_by,
);
}
if (notification.body.version_id) {
notification.extra_data.version = versions.find(
(x) => x.id === notification.body.version_id,
);
}
}
}
return notifications;
}
export function groupNotifications(notifications) {
const grouped = [];
for (let i = 0; i < notifications.length; i++) {
const current = notifications[i];
const next = notifications[i + 1];
if (current.body && i < notifications.length - 1 && isSimilar(current, next)) {
current.grouped_notifs = [next];
let j = i + 2;
while (j < notifications.length && isSimilar(current, notifications[j])) {
current.grouped_notifs.push(notifications[j]);
j++;
}
grouped.push(current);
i = j - 1; // skip i to the last ungrouped
} else {
grouped.push(current);
}
}
return grouped;
}
function isSimilar(notifA, notifB) {
return !!notifA.body.project_id && notifA.body.project_id === notifB.body.project_id;
}
export async function markAsRead(ids) {
try {
await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
method: "PATCH",
});
return (notifications) => {
const newNotifs = notifications;
newNotifs.forEach((notif) => {
if (ids.includes(notif.id)) {
notif.read = true;
}
});
return newNotifs;
};
} catch (err) {
const app = useNuxtApp();
app.$notify({
group: "main",
title: "Error marking notification as read",
text: err.data ? err.data.description : err,
type: "error",
});
return () => {};
}
}

View File

@@ -0,0 +1,185 @@
import { useNuxtApp } from "#imports";
// TODO: There needs to be a standardized way to get these types, eg; @modrinth/types generated from api schema. Later problem.
type Project = { id: string };
type Version = { id: string; project_id: string };
type Report = { id: string; item_type: "project" | "user" | "version"; item_id: string };
type Thread = { id: string };
type User = { id: string };
type Organization = { id: string };
export type NotificationAction = {
title: string;
action_route: [string, string];
};
export type NotificationBody = {
project_id?: string;
version_id?: string;
report_id?: string;
thread_id?: string;
invited_by?: string;
organization_id?: string;
};
export type Notification = {
id: string;
user_id: string;
type: "project_update" | "team_invite" | "status_change" | "moderator_message";
title: string;
text: string;
link: string;
read: boolean;
created: string;
actions: NotificationAction[];
body?: NotificationBody;
extra_data?: Record<string, unknown>;
grouped_notifs?: Notification[];
};
async function getBulk<T extends { id: string }>(
type: string,
ids: string[],
apiVersion = 2,
): Promise<T[]> {
if (!ids || ids.length === 0) {
return [];
}
const url = `${type}?ids=${encodeURIComponent(JSON.stringify([...new Set(ids)]))}`;
try {
const res = await useBaseFetch(url, { apiVersion });
return Array.isArray(res) ? res : [];
} catch {
return [];
}
}
export async function fetchExtraNotificationData(
notifications: Notification[],
): Promise<Notification[]> {
const bulk = {
projects: [] as string[],
reports: [] as string[],
threads: [] as string[],
users: [] as string[],
versions: [] as string[],
organizations: [] as string[],
};
for (const notification of notifications) {
if (notification.body) {
if (notification.body.project_id) bulk.projects.push(notification.body.project_id);
if (notification.body.version_id) bulk.versions.push(notification.body.version_id);
if (notification.body.report_id) bulk.reports.push(notification.body.report_id);
if (notification.body.thread_id) bulk.threads.push(notification.body.thread_id);
if (notification.body.invited_by) bulk.users.push(notification.body.invited_by);
if (notification.body.organization_id)
bulk.organizations.push(notification.body.organization_id);
}
}
const reports = (await getBulk<Report>("reports", bulk.reports)).filter(Boolean);
for (const r of reports) {
if (!r?.item_type) continue;
if (r.item_type === "project") bulk.projects.push(r.item_id);
else if (r.item_type === "user") bulk.users.push(r.item_id);
else if (r.item_type === "version") bulk.versions.push(r.item_id);
}
const versions = (await getBulk<Version>("versions", bulk.versions)).filter(Boolean);
for (const v of versions) bulk.projects.push(v.project_id);
const [projects, threads, users, organizations] = await Promise.all([
getBulk<Project>("projects", bulk.projects),
getBulk<Thread>("threads", bulk.threads),
getBulk<User>("users", bulk.users),
getBulk<Organization>("organizations", bulk.organizations, 3),
]);
for (const n of notifications) {
n.extra_data = {};
if (n.body) {
if (n.body.project_id)
n.extra_data.project = projects.find((x) => x.id === n.body!.project_id);
if (n.body.organization_id)
n.extra_data.organization = organizations.find((x) => x.id === n.body!.organization_id);
if (n.body.report_id) {
n.extra_data.report = reports.find((x) => x.id === n.body!.report_id);
const t = (n.extra_data.report as Report | undefined)?.item_type;
if (t === "project")
n.extra_data.project = projects.find(
(x) => x.id === (n.extra_data?.report as Report | undefined)?.item_id,
);
else if (t === "user")
n.extra_data.user = users.find(
(x) => x.id === (n.extra_data?.report as Report | undefined)?.item_id,
);
else if (t === "version") {
n.extra_data.version = versions.find(
(x) => x.id === (n.extra_data?.report as Report | undefined)?.item_id,
);
n.extra_data.project = projects.find(
(x) => x.id === (n.extra_data?.version as Version | undefined)?.project_id,
);
}
}
if (n.body.thread_id) n.extra_data.thread = threads.find((x) => x.id === n.body!.thread_id);
if (n.body.invited_by)
n.extra_data.invited_by = users.find((x) => x.id === n.body!.invited_by);
if (n.body.version_id)
n.extra_data.version = versions.find((x) => x.id === n.body!.version_id);
}
}
return notifications;
}
export function groupNotifications(notifications: Notification[]): Notification[] {
const grouped: Notification[] = [];
for (let i = 0; i < notifications.length; i++) {
const current = notifications[i];
const next = notifications[i + 1];
if (current.body && i < notifications.length - 1 && isSimilar(current, next)) {
current.grouped_notifs = [next];
let j = i + 2;
while (j < notifications.length && isSimilar(current, notifications[j])) {
current.grouped_notifs.push(notifications[j]);
j++;
}
grouped.push(current);
i = j - 1;
} else {
grouped.push(current);
}
}
return grouped;
}
function isSimilar(a: Notification, b: Notification | undefined): boolean {
return !!a?.body?.project_id && a.body!.project_id === b?.body?.project_id;
}
export async function markAsRead(
ids: string[],
): Promise<(notifications: Notification[]) => Notification[]> {
try {
await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
method: "PATCH",
});
return (notifications: Notification[]) => {
const newNotifs = notifications ?? [];
newNotifs.forEach((n) => {
if (ids.includes(n.id)) n.read = true;
});
return newNotifs;
};
} catch (err: any) {
const app: any = useNuxtApp();
app.$notify({
group: "main",
title: "Error marking notification as read",
text: err?.data?.description ?? err,
type: "error",
});
return () => [];
}
}

View File

@@ -27,76 +27,90 @@
</div>
</div>
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
<div
<PagewideBanner
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
class="email-nag"
variant="warning"
>
<template v-if="auth.user.email">
<span>{{ formatMessage(verifyEmailBannerMessages.title) }}</span>
<button class="btn" @click="resendVerifyEmail">
<template #title>
<span>
{{
auth?.user?.email
? formatMessage(verifyEmailBannerMessages.title)
: formatMessage(addEmailBannerMessages.title)
}}
</span>
</template>
<template #description>
<span>
{{
auth?.user?.email
? formatMessage(verifyEmailBannerMessages.description)
: formatMessage(addEmailBannerMessages.description)
}}
</span>
</template>
<template #actions>
<button v-if="auth?.user?.email" class="btn" @click="resendVerifyEmail">
{{ formatMessage(verifyEmailBannerMessages.action) }}
</button>
</template>
<template v-else>
<span>{{ formatMessage(addEmailBannerMessages.title) }}</span>
<nuxt-link class="btn" to="/settings/account">
<nuxt-link v-else class="btn" to="/settings/account">
<SettingsIcon aria-hidden="true" />
{{ formatMessage(addEmailBannerMessages.action) }}
</nuxt-link>
</template>
</div>
<div
</PagewideBanner>
<PagewideBanner
v-if="
user &&
user.subscriptions &&
user.subscriptions.some((x) => x.status === 'payment-failed') &&
route.path !== '/settings/billing'
"
class="email-nag"
variant="error"
>
<span>{{ formatMessage(subscriptionPaymentFailedBannerMessages.title) }}</span>
<nuxt-link class="btn" to="/settings/billing">
<SettingsIcon aria-hidden="true" />
{{ formatMessage(subscriptionPaymentFailedBannerMessages.action) }}
</nuxt-link>
</div>
<div
<template #title>
<span>{{ formatMessage(subscriptionPaymentFailedBannerMessages.title) }}</span>
</template>
<template #description>
<span>{{ formatMessage(subscriptionPaymentFailedBannerMessages.description) }}</span>
</template>
<template #actions>
<nuxt-link class="btn" to="/settings/billing">
<SettingsIcon aria-hidden="true" />
{{ formatMessage(subscriptionPaymentFailedBannerMessages.action) }}
</nuxt-link>
</template>
</PagewideBanner>
<PagewideBanner
v-if="
config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com') &&
!cosmetics.hideStagingBanner
"
class="site-banner site-banner--warning [&>*]:z-[6]"
variant="warning"
>
<div class="site-banner__title">
<IssuesIcon aria-hidden="true" />
<template #title>
<span>{{ formatMessage(stagingBannerMessages.title) }}</span>
</div>
<div class="site-banner__description">
</template>
<template #description>
{{ formatMessage(stagingBannerMessages.description) }}
</div>
<div class="site-banner__actions">
<Button transparent icon-only :action="hideStagingBanner" aria-label="Close banner"
><XIcon aria-hidden="true"
/></Button>
</div>
</div>
<div
v-if="generatedStateErrors && generatedStateErrors.length > 0"
class="site-banner site-banner--warning [&>*]:z-[6]"
>
<div class="site-banner__title">
<IssuesIcon aria-hidden="true" />
</template>
<template #actions_right>
<Button transparent icon-only aria-label="Close" @click="hideStagingBanner">
<XIcon aria-hidden="true" />
</Button>
</template>
</PagewideBanner>
<PagewideBanner v-if="generatedStateErrors?.length" variant="error">
<template #title>
<span>{{ formatMessage(failedToBuildBannerMessages.title) }}</span>
</div>
<div class="site-banner__description">
</template>
<template #description>
{{
formatMessage(failedToBuildBannerMessages.description, {
errors: generatedStateErrors,
url: config.public.apiBaseUrl,
})
}}
</div>
</div>
</template>
</PagewideBanner>
<header
class="experimental-styles-within desktop-only relative z-[5] mx-auto grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-6 py-4 lg:grid-cols-[auto_1fr_auto]"
>
@@ -692,7 +706,14 @@ import {
GitHubIcon,
ScaleIcon,
} from "@modrinth/assets";
import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui";
import {
Button,
ButtonStyled,
OverflowMenu,
PagewideBanner,
Avatar,
commonMessages,
} from "@modrinth/ui";
import { isAdmin, isStaff } from "@modrinth/utils";
import { errors as generatedStateErrors } from "~/generated/state.json";
@@ -720,8 +741,13 @@ const basePopoutId = useId();
const verifyEmailBannerMessages = defineMessages({
title: {
id: "layout.banner.verify-email.title",
defaultMessage: "For security purposes, please verify your email address on Modrinth.",
id: "layout.banner.account-action",
defaultMessage: "Account action required",
},
description: {
id: "layout.banner.verify-email.description",
defaultMessage:
"For security reasons, Modrinth needs you to verify the email address associated with your account.",
},
action: {
id: "layout.banner.verify-email.action",
@@ -731,8 +757,13 @@ const verifyEmailBannerMessages = defineMessages({
const addEmailBannerMessages = defineMessages({
title: {
id: "layout.banner.add-email.title",
defaultMessage: "For security purposes, please enter your email on Modrinth.",
id: "layout.banner.account-action",
defaultMessage: "Account action required",
},
description: {
id: "layout.banner.add-email.description",
defaultMessage:
"For security reasons, Modrinth needs you to register an email address to your account.",
},
action: {
id: "layout.banner.add-email.button",
@@ -743,8 +774,12 @@ const addEmailBannerMessages = defineMessages({
const subscriptionPaymentFailedBannerMessages = defineMessages({
title: {
id: "layout.banner.subscription-payment-failed.title",
defaultMessage: "Billing action required.",
},
description: {
id: "layout.banner.subscription-payment-failed.description",
defaultMessage:
"Your subscription failed to renew. Please update your payment method to prevent losing access.",
"One or more subscriptions failed to renew. Please update your payment method to prevent losing access!",
},
action: {
id: "layout.banner.subscription-payment-failed.button",
@@ -755,7 +790,7 @@ const subscriptionPaymentFailedBannerMessages = defineMessages({
const stagingBannerMessages = defineMessages({
title: {
id: "layout.banner.staging.title",
defaultMessage: "Youre viewing Modrinths staging environment.",
defaultMessage: "Youre viewing Modrinths staging environment",
},
description: {
id: "layout.banner.staging.description",
@@ -1052,7 +1087,6 @@ watch(
document.body.removeAttribute("tabindex");
}
updateCurrentDate();
runAnalytics();
},
);
@@ -1348,72 +1382,6 @@ const footerLinks = [
}
}
.email-nag {
z-index: 6;
position: relative;
background-color: var(--color-raised-bg);
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0.5rem 1rem;
}
.site-banner--warning {
// On some pages, there's gradient backgrounds that seep underneath
// the banner, so we need to add a solid color underlay.
background-color: black;
border-bottom: 2px solid var(--color-red);
display: grid;
gap: 0.5rem;
grid-template: "title actions" "description actions";
padding-block: var(--gap-xl);
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
z-index: 4;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-red-bg);
z-index: 5;
}
.site-banner__title {
grid-area: title;
display: flex;
gap: 0.5rem;
align-items: center;
font-weight: bold;
font-size: var(--font-size-md);
color: var(--color-contrast);
svg {
color: var(--color-red);
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
}
}
.site-banner__description {
grid-area: description;
}
.site-banner__actions {
grid-area: actions;
}
a {
color: var(--color-red);
}
}
@media (max-width: 1200px) {
.app-btn {
display: none;

View File

@@ -159,7 +159,7 @@
"message": "Subscribe to updates about Modrinth"
},
"auth.welcome.description": {
"message": "Thank you for creating an account. You can now follow and create projects, receive updates about your favorite projects, and more!"
"message": "Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods."
},
"auth.welcome.label.tos": {
"message": "By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>."
@@ -344,35 +344,38 @@
"layout.avatar.alt": {
"message": "Your avatar"
},
"layout.banner.account-action": {
"message": "Account action required"
},
"layout.banner.add-email.button": {
"message": "Visit account settings"
},
"layout.banner.add-email.title": {
"message": "For security purposes, please enter your email on Modrinth."
},
"layout.banner.build-fail.description": {
"message": "This deploy of Modrinth's frontend failed to generate state from the API. This may be due to an outage or an error in configuration. Rebuild when the API is available. Error codes: {errors}; Current API URL is: {url}"
},
"layout.banner.build-fail.title": {
"message": "Error generating state from API when building."
"message": "Error generating state from API when building"
},
"layout.banner.staging.description": {
"message": "The staging environment is completely separate from the production Modrinth database. This is used for testing and debugging purposes, and may be running in-development versions of the Modrinth backend or frontend newer than the production instance."
},
"layout.banner.staging.title": {
"message": "Youre viewing Modrinths staging environment."
"message": "Youre viewing Modrinths staging environment"
},
"layout.banner.subscription-payment-failed.button": {
"message": "Update billing info"
},
"layout.banner.subscription-payment-failed.title": {
"message": "Your subscription failed to renew. Please update your payment method to prevent losing access."
"message": "Billing action required"
},
"layout.banner.subscription-payment-failed.description": {
"message": "One or more subscriptions failed to renew. Please update your payment method to prevent losing access!"
},
"layout.banner.verify-email.action": {
"message": "Re-send verification email"
},
"layout.banner.verify-email.title": {
"message": "For security purposes, please verify your email address on Modrinth."
"layout.banner.verify-email.description": {
"message": "For security reasons, Modrinth needs you to verify the email address associated with your account."
},
"layout.footer.about": {
"message": "About"

View File

@@ -871,6 +871,7 @@ import {
ProjectSidebarDetails,
ProjectSidebarLinks,
ScrollablePanel,
useRelativeTime,
} from "@modrinth/ui";
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";

View File

@@ -77,7 +77,7 @@
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>.
<a href="https://support.modrinth.com">Modrinth Support</a>.
</p>
<ConversationThread
v-if="thread"

View File

@@ -541,7 +541,6 @@
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose loaders..."
/>
@@ -566,7 +565,6 @@
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
:custom-label="(version) => version"
placeholder="Choose versions..."

View File

@@ -92,7 +92,7 @@
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
{{ dayjs(subscription.created).format("MMMM D, YYYY [at] h:mma") }} ({{
dayjs(subscription.created).fromNow()
formatRelativeTime(subscription.created)
}})
</div>
</div>
@@ -151,7 +151,7 @@
</span>
<span class="text-sm text-secondary">
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ dayjs(charge.due).fromNow() }}) </span>
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<div
v-if="flags.developerMode"
@@ -196,7 +196,15 @@
</div>
</template>
<script setup>
import { Avatar, ButtonStyled, CopyCode, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
import {
Avatar,
ButtonStyled,
CopyCode,
DropdownSelect,
NewModal,
Toggle,
useRelativeTime,
} from "@modrinth/ui";
import { formatCategory, formatPrice } from "@modrinth/utils";
import {
CheckIcon,
@@ -215,7 +223,9 @@ const flags = useFeatureFlags();
const route = useRoute();
const data = useNuxtApp();
const vintl = useVIntl();
const { formatMessage } = vintl;
const formatRelativeTime = useRelativeTime();
const messages = defineMessages({
userNotFoundError: {

View File

@@ -156,7 +156,7 @@
<div class="text-sm">
<span v-if="notice.announce_at">
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
dayjs(notice.announce_at).fromNow()
formatRelativeTime(notice.announce_at)
}})
</span>
<template v-else> Never begins </template>
@@ -166,7 +166,7 @@
v-if="notice.expires"
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
>
{{ dayjs(notice.expires).fromNow() }}
{{ formatRelativeTime(notice.expires) }}
</span>
<template v-else> Never expires </template>
</div>
@@ -267,6 +267,7 @@ import {
NewModal,
TeleportDropdownMenu,
Toggle,
useRelativeTime,
} from "@modrinth/ui";
import { SettingsIcon, PlusIcon, SaveIcon, TrashIcon, EditIcon, XIcon } from "@modrinth/assets";
import dayjs from "dayjs";
@@ -278,6 +279,8 @@ import { usePyroFetch } from "~/composables/pyroFetch.ts";
import AssignNoticeModal from "~/components/ui/servers/notice/AssignNoticeModal.vue";
const { formatMessage } = useVIntl();
const formatRelativeTime = useRelativeTime();
const app = useNuxtApp() as unknown as { $notify: any };
const notices = ref<ServerNoticeType[]>([]);

View File

@@ -1,10 +1,20 @@
<template>
<div>
<h1>{{ formatMessage(messages.welcomeLongTitle) }}</h1>
<div class="welcome-box has-bot">
<img :src="WavingRinthbot" alt="Waving Modrinth Bot" class="welcome-box__waving-bot" />
<div class="welcome-box__top-glow" />
<div class="welcome-box__body">
<h1 class="welcome-box__title">
{{ formatMessage(messages.welcomeLongTitle) }}
</h1>
<section class="auth-form">
<p>
{{ formatMessage(messages.welcomeDescription) }}
<p class="welcome-box__subtitle">
<IntlFormatted :message-id="messages.welcomeDescription">
<template #bold="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
</p>
<Checkbox
@@ -14,11 +24,12 @@
:description="formatMessage(messages.subscribeCheckbox)"
/>
<button class="btn btn-primary continue-btn centered-btn" @click="continueSignUp">
{{ formatMessage(commonMessages.continueButton) }} <RightArrowIcon />
<button class="btn btn-primary centered-btn" @click="continueSignUp">
{{ formatMessage(commonMessages.continueButton) }}
<RightArrowIcon />
</button>
<p>
<p class="tos-text">
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
@@ -32,12 +43,15 @@
</template>
</IntlFormatted>
</p>
</section>
</div>
</div>
</template>
<script setup>
import { Checkbox, commonMessages } from "@modrinth/ui";
import { RightArrowIcon } from "@modrinth/assets";
import { RightArrowIcon, WavingRinthbot } from "@modrinth/assets";
const route = useRoute();
const { formatMessage } = useVIntl();
@@ -54,7 +68,7 @@ const messages = defineMessages({
welcomeDescription: {
id: "auth.welcome.description",
defaultMessage:
"Thank you for creating an account. You can now follow and create projects, receive updates about your favorite projects, and more!",
"Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods.",
},
welcomeLongTitle: {
id: "auth.welcome.long-title",
@@ -72,20 +86,18 @@ useHead({
const subscribe = ref(true);
async function continueSignUp() {
const route = useRoute();
onMounted(async () => {
await useAuth(route.query.authToken);
await useUser();
});
async function continueSignUp() {
if (subscribe.value) {
try {
await useBaseFetch("auth/email/subscribe", {
method: "POST",
});
} catch {
/* empty */
}
} catch {}
}
if (route.query.redirect) {
@@ -95,3 +107,84 @@ async function continueSignUp() {
}
}
</script>
<style lang="scss" scoped>
.welcome-box {
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-lg);
padding: 1.75rem 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
box-shadow: var(--shadow-card);
position: relative;
&.has-bot {
margin-block: 120px;
}
p {
margin: 0;
}
a {
color: var(--color-brand);
font-weight: var(--weight-bold);
&:hover,
&:focus {
filter: brightness(1.125);
text-decoration: underline;
}
}
&__waving-bot {
--bot-height: 112px;
position: absolute;
top: calc(-1 * var(--bot-height));
right: 5rem;
height: var(--bot-height);
width: auto;
@media (max-width: 768px) {
--bot-height: 70px;
right: 2rem;
}
}
&__top-glow {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1px;
opacity: 0.4;
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
}
&__body {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
&__title {
font-size: var(--text-32);
font-weight: var(--weight-extrabold);
margin: 0;
}
&__subtitle {
font-size: var(--text-18);
}
.tos-text {
font-size: var(--text-14);
line-height: 1.5;
}
}
</style>

View File

@@ -391,6 +391,7 @@ import {
DropdownSelect,
FileInput,
PopoutMenu,
useRelativeTime,
} from "@modrinth/ui";
import { isAdmin } from "@modrinth/utils";

View File

@@ -99,7 +99,7 @@
import { ChevronRightIcon, HistoryIcon } from "@modrinth/assets";
import Avatar from "~/components/ui/Avatar.vue";
import NotificationItem from "~/components/ui/NotificationItem.vue";
import { fetchExtraNotificationData, groupNotifications } from "~/helpers/notifications.js";
import { fetchExtraNotificationData, groupNotifications } from "~/helpers/notifications.ts";
useHead({
title: "Dashboard - Modrinth",

View File

@@ -12,7 +12,7 @@
<h2 v-else class="text-2xl">Notifications</h2>
</div>
<template v-if="!history">
<Button v-if="hasRead" @click="updateRoute()">
<Button v-if="data.hasRead" @click="updateRoute()">
<HistoryIcon />
View history
</Button>
@@ -60,7 +60,7 @@ import {
fetchExtraNotificationData,
groupNotifications,
markAsRead,
} from "~/helpers/notifications.js";
} from "~/helpers/notifications.ts";
import NotificationItem from "~/components/ui/NotificationItem.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import Pagination from "~/components/ui/Pagination.vue";
@@ -70,93 +70,69 @@ useHead({
});
const auth = await useAuth();
const route = useNativeRoute();
const router = useNativeRouter();
const history = computed(() => {
return route.name === "dashboard-notifications-history";
});
const history = computed(() => route.name === "dashboard-notifications-history");
const selectedType = ref("all");
const page = ref(1);
const perPage = ref(50);
const { data, pending, error, refresh } = await useAsyncData(
async () => {
const pageNum = page.value - 1;
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`);
const showRead = history.value;
const hasRead = notifications.some((notif) => notif.read);
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`);
const types = [
...new Set(
notifications
.filter((notification) => {
return showRead || !notification.read;
})
.map((notification) => notification.type),
),
const typesInFeed = [
...new Set(notifications.filter((n) => showRead || !n.read).map((n) => n.type)),
];
const filteredNotifications = notifications.filter(
(notification) =>
(selectedType.value === "all" || notification.type === selectedType.value) &&
(showRead || !notification.read),
const filtered = notifications.filter(
(n) =>
(selectedType.value === "all" || n.type === selectedType.value) && (showRead || !n.read),
);
const pages = Math.ceil(filteredNotifications.length / perPage.value);
const pages = Math.max(1, Math.ceil(filtered.length / perPage.value));
return fetchExtraNotificationData(
filteredNotifications.slice(pageNum * perPage.value, perPage.value + pageNum * perPage.value),
).then((notifications) => {
return {
notifications,
types: types.length > 1 ? ["all", ...types] : types,
pages,
hasRead,
};
});
filtered.slice(pageNum * perPage.value, pageNum * perPage.value + perPage.value),
).then((notifs) => ({
notifications: notifs,
notifTypes: typesInFeed.length > 1 ? ["all", ...typesInFeed] : typesInFeed,
pages,
hasRead: notifications.some((n) => n.read),
}));
},
{ watch: [page, history, selectedType] },
);
const notifications = computed(() => {
if (data.value === null) {
return [];
}
return groupNotifications(data.value.notifications, history.value);
});
const notifTypes = computed(() => data.value.types);
const pages = computed(() => data.value.pages);
const hasRead = computed(() => data.value.hasRead);
const notifications = computed(() =>
data.value ? groupNotifications(data.value.notifications, history.value) : [],
);
const notifTypes = computed(() => data.value?.notifTypes || []);
const pages = computed(() => data.value?.pages ?? 1);
function updateRoute() {
if (history.value) {
router.push("/dashboard/notifications");
} else {
router.push("/dashboard/notifications/history");
}
router.push(history.value ? "/dashboard/notifications" : "/dashboard/notifications/history");
selectedType.value = "all";
page.value = 1;
}
async function readAll() {
const ids = notifications.value.flatMap((notification) => [
notification.id,
...(notification.grouped_notifs ? notification.grouped_notifs.map((notif) => notif.id) : []),
const ids = notifications.value.flatMap((n) => [
n.id,
...(n.grouped_notifs ? n.grouped_notifs.map((g) => g.id) : []),
]);
const updateNotifs = await markAsRead(ids);
allNotifs.value = updateNotifs(allNotifs.value);
await markAsRead(ids);
await refresh();
}
function changePage(newPage) {
page.value = newPage;
if (import.meta.client) {
window.scrollTo({ top: 0, behavior: "smooth" });
}
if (import.meta.client) window.scrollTo({ top: 0, behavior: "smooth" });
}
</script>
<style lang="scss" scoped>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useRelativeTime } from "@modrinth/ui";
const vintl = useVIntl();
const { formatMessage } = vintl;

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