Compare commits
74 Commits
v0.9.5
...
cal/dev-64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46fdb29ba6 | ||
|
|
a3839461cf | ||
|
|
858c7e393f | ||
|
|
0278241006 | ||
|
|
3afb682fc6 | ||
|
|
06f1df1995 | ||
|
|
3489771d2e | ||
|
|
448ae5a2b7 | ||
|
|
72340790e5 | ||
|
|
c9423fe478 | ||
|
|
02a850ae63 | ||
|
|
ede6d0c3cc | ||
|
|
7685989a8c | ||
|
|
4e8ebb5e5c | ||
|
|
3f77ab19ed | ||
|
|
d3d0c8c523 | ||
|
|
4e093131f3 | ||
|
|
6ca8a4e5fd | ||
|
|
63b15ded60 | ||
|
|
85e65aeffe | ||
|
|
ad44398492 | ||
|
|
a4ba41bf15 | ||
|
|
4441be5380 | ||
|
|
c0accb42fa | ||
|
|
7223c2b197 | ||
|
|
7b535a1c2a | ||
|
|
0aa76567a6 | ||
|
|
6fa1369c49 | ||
|
|
b66d99c59c | ||
|
|
a9cfc37aac | ||
|
|
be37f077d3 | ||
|
|
f52d020a3c | ||
|
|
74cf3f076e | ||
|
|
84adf79564 | ||
|
|
dc0d923cee | ||
|
|
2ffd7476aa | ||
|
|
034fd06284 | ||
|
|
49aac6bdca | ||
|
|
4e4a7be7ef | ||
|
|
9c1bdf16e4 | ||
|
|
9e527ff141 | ||
|
|
c6022ad977 | ||
|
|
ea9a3539eb | ||
|
|
e4f0dddf82 | ||
|
|
be425cff6f | ||
|
|
e225bc9f66 | ||
|
|
f19643095e | ||
|
|
37cc81a36d | ||
|
|
863bf62f8d | ||
|
|
9a6390bb4d | ||
|
|
62de07e4e6 | ||
|
|
6e46317a37 | ||
|
|
b59f208e91 | ||
|
|
8fdc7403b1 | ||
|
|
895cd11e30 | ||
|
|
58ce3a4967 | ||
|
|
bdd4deb302 | ||
|
|
ec2a56cd73 | ||
|
|
10a5864a47 | ||
|
|
16766be82f | ||
|
|
1884410e0d | ||
|
|
6d57da2053 | ||
|
|
e4adbb9469 | ||
|
|
8ee621295c | ||
|
|
32920dd825 | ||
|
|
2d5d2d5df8 | ||
|
|
8dd32bbe98 | ||
|
|
f71830e0fa | ||
|
|
bd0d6a9ac0 | ||
|
|
dfef0df464 | ||
|
|
9821737431 | ||
|
|
f932ce7706 | ||
|
|
de3019e92b | ||
|
|
20b616a7c4 |
2
.github/ISSUE_TEMPLATE/1-app-bug.yml
vendored
@@ -6,7 +6,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Please confirm the following.
|
label: Please confirm the following.
|
||||||
options:
|
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
|
required: true
|
||||||
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/2-web-bug.yml
vendored
@@ -6,7 +6,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Please confirm the following.
|
label: Please confirm the following.
|
||||||
options:
|
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
|
required: true
|
||||||
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/3-api-bug.yml
vendored
@@ -6,7 +6,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Please confirm the following.
|
label: Please confirm the following.
|
||||||
options:
|
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
|
required: true
|
||||||
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/4-feature-request.yml
vendored
@@ -7,7 +7,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Please confirm the following.
|
label: Please confirm the following.
|
||||||
options:
|
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
|
required: true
|
||||||
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
|
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
BIN
.github/assets/api_cover.png
vendored
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 8.0 KiB |
BIN
.github/assets/app_cover.png
vendored
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 17 KiB |
BIN
.github/assets/monorepo_cover.png
vendored
|
Before Width: | Height: | Size: 417 KiB After Width: | Height: | Size: 262 KiB |
BIN
.github/assets/web_cover.png
vendored
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 24 KiB |
1
.vscode/settings.json
vendored
@@ -2,6 +2,7 @@
|
|||||||
"prettier.endOfLine": "lf",
|
"prettier.endOfLine": "lf",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||||
|
"editor.detectIndentation": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": "explicit"
|
"source.fixAll.eslint": "explicit"
|
||||||
}
|
}
|
||||||
|
|||||||
5116
Cargo.lock
generated
190
Cargo.toml
@@ -1,25 +1,183 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = '2'
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
'./packages/app-lib',
|
"apps/app",
|
||||||
'./apps/app-playground',
|
"apps/app-playground",
|
||||||
'./apps/app',
|
"apps/daedalus_client",
|
||||||
'./apps/labrinth',
|
"apps/labrinth",
|
||||||
'./apps/daedalus_client',
|
"packages/app-lib",
|
||||||
'./packages/daedalus',
|
"packages/ariadne",
|
||||||
'./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.24", 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.1"
|
||||||
|
bytes = "1.10.1"
|
||||||
|
censor = "0.3.0"
|
||||||
|
chrono = "0.4.41"
|
||||||
|
clap = "4.5.40"
|
||||||
|
clickhouse = "0.13.3"
|
||||||
|
color-thief = "0.2.2"
|
||||||
|
console-subscriber = "0.4.1"
|
||||||
|
daedalus = { path = "packages/daedalus" }
|
||||||
|
dashmap = "6.1.0"
|
||||||
|
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.2"
|
||||||
|
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.14"
|
||||||
|
iana-time-zone = "0.1.63"
|
||||||
|
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||||
|
indexmap = "2.9.0"
|
||||||
|
indicatif = "0.17.11"
|
||||||
|
itertools = "0.14.0"
|
||||||
|
jemalloc_pprof = "0.7.0"
|
||||||
|
json-patch = { version = "4.0.0", default-features = false }
|
||||||
|
lettre = { version = "0.11.17", default-features = false, features = [
|
||||||
|
"builder",
|
||||||
|
"hostname",
|
||||||
|
"pool",
|
||||||
|
"ring",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"smtp-transport",
|
||||||
|
] }
|
||||||
|
maxminddb = "0.26.0"
|
||||||
|
meilisearch-sdk = { version = "0.28.0", default-features = false }
|
||||||
|
murmur2 = "0.1.0"
|
||||||
|
native-dialog = "0.9.0"
|
||||||
|
notify = { version = "8.0.0", default-features = false }
|
||||||
|
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||||
|
p256 = "0.13.2"
|
||||||
|
paste = "1.0.15"
|
||||||
|
prometheus = "0.14.0"
|
||||||
|
quartz_nbt = "0.2.9"
|
||||||
|
quick-xml = "0.37.5"
|
||||||
|
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||||
|
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||||
|
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
|
||||||
|
regex = "1.11.1"
|
||||||
|
reqwest = { version = "0.12.19", 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.1" # 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.6", default-features = false }
|
||||||
|
sysinfo = { version = "0.35.2", default-features = false }
|
||||||
|
tar = "0.4.44"
|
||||||
|
tauri = "2.5.1"
|
||||||
|
tauri-build = "2.2.0"
|
||||||
|
tauri-plugin-deep-link = "2.3.0"
|
||||||
|
tauri-plugin-dialog = "2.2.2"
|
||||||
|
tauri-plugin-opener = "2.2.7"
|
||||||
|
tauri-plugin-os = "2.2.1"
|
||||||
|
tauri-plugin-single-instance = "2.2.4"
|
||||||
|
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
|
||||||
|
"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.1"
|
||||||
|
tokio-stream = "0.1.17"
|
||||||
|
tokio-util = "0.7.15"
|
||||||
|
totp-rs = "5.7.0"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-actix-web = "0.7.18"
|
||||||
|
tracing-error = "0.2.1"
|
||||||
|
tracing-subscriber = "0.3.19"
|
||||||
|
url = "2.5.4"
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
uuid = "1.17.0"
|
||||||
|
validator = "0.20.0"
|
||||||
|
webp = { version = "0.3.0", default-features = false }
|
||||||
|
whoami = "1.6.0"
|
||||||
|
winreg = "0.55.0"
|
||||||
|
woothee = "0.13.0"
|
||||||
|
yaserde = "0.12.0"
|
||||||
|
zip = { version = "4.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
|
# Optimize for speed and reduce size on release builds
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort" # Strip expensive panic clean-up logic
|
opt-level = "s" # Optimize for binary size
|
||||||
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
|
strip = true # Remove debug symbols
|
||||||
lto = true # Enables link to optimizations
|
lto = true # Enables link to optimizations
|
||||||
opt-level = "s" # Optimize for binary size
|
panic = "abort" # Strip expensive panic clean-up logic
|
||||||
strip = true # Remove debug symbols
|
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
|
||||||
|
|
||||||
[profile.dev.package.sqlx-macros]
|
[profile.dev.package.sqlx-macros]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }
|
|
||||||
|
|||||||
@@ -17,12 +17,12 @@
|
|||||||
"@modrinth/utils": "workspace:*",
|
"@modrinth/utils": "workspace:*",
|
||||||
"@sentry/vue": "^8.27.0",
|
"@sentry/vue": "^8.27.0",
|
||||||
"@geometrically/minecraft-motd-parser": "^1.1.4",
|
"@geometrically/minecraft-motd-parser": "^1.1.4",
|
||||||
"@tauri-apps/api": "^2.1.1",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||||
"@tauri-apps/plugin-os": "^2.2.0",
|
"@tauri-apps/plugin-os": "^2.2.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.2.1",
|
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||||
"@tauri-apps/plugin-updater": "^2.3.0",
|
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||||
"@tauri-apps/plugin-window-state": "^2.2.0",
|
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||||
"@vintl/vintl": "^4.4.1",
|
"@vintl/vintl": "^4.4.1",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ import {
|
|||||||
WorldIcon,
|
WorldIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} 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 { useLoading, useTheming } from '@/store/state'
|
||||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
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 { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
|
||||||
const themeStore = useTheming()
|
const themeStore = useTheming()
|
||||||
|
|
||||||
const news = ref([])
|
const news = ref([])
|
||||||
@@ -590,7 +599,7 @@ function handleAuxClick(e) {
|
|||||||
</h4>
|
</h4>
|
||||||
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
|
<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">
|
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
|
||||||
{{ dayjs(item.date).fromNow() }}
|
{{ formatRelativeTime(dayjs(item.date).toISOString()) }}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
<hr
|
<hr
|
||||||
|
|||||||
BIN
apps/app-frontend/src/assets/external/gdlauncher.png
vendored
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 937 KiB After Width: | Height: | Size: 270 KiB |
@@ -9,7 +9,7 @@ import {
|
|||||||
StopCircleIcon,
|
StopCircleIcon,
|
||||||
TimerIcon,
|
TimerIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { Avatar, ButtonStyled } from '@modrinth/ui'
|
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
import { finish_install, kill, run } from '@/helpers/profile'
|
import { finish_install, kill, run } from '@/helpers/profile'
|
||||||
import { get_by_profile_path } from '@/helpers/process'
|
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 { handleSevereError } from '@/store/error.js'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
||||||
import { formatCategory } from '@modrinth/utils'
|
import { formatCategory } from '@modrinth/utils'
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
instance: {
|
instance: {
|
||||||
@@ -173,7 +172,9 @@ onUnmounted(() => unlisten())
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||||
<TimerIcon />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui'
|
||||||
import {
|
import {
|
||||||
UserPlusIcon,
|
UserPlusIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
@@ -18,6 +18,8 @@ import type { Dayjs } from 'dayjs'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
credentials: unknown | null
|
credentials: unknown | null
|
||||||
signIn: () => void
|
signIn: () => void
|
||||||
@@ -205,7 +207,9 @@ onUnmounted(() => {
|
|||||||
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
|
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</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>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<template v-if="friend.id === userCredentials.user_id">
|
<template v-if="friend.id === userCredentials.user_id">
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import {
|
|||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
StopCircleIcon,
|
StopCircleIcon,
|
||||||
} from '@modrinth/assets'
|
} 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 { useVIntl } from '@vintl/vintl'
|
||||||
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
|
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { showProfileInFolder } from '@/helpers/utils'
|
import { showProfileInFolder } from '@/helpers/utils'
|
||||||
@@ -25,6 +32,7 @@ import { handleError } from '@/store/notifications'
|
|||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -144,7 +152,7 @@ onUnmounted(() => {
|
|||||||
<template v-if="instance.last_played">
|
<template v-if="instance.last_played">
|
||||||
{{
|
{{
|
||||||
formatMessage(commonMessages.playedLabel, {
|
formatMessage(commonMessages.playedLabel, {
|
||||||
time: dayjs(instance.last_played).fromNow(),
|
time: formatRelativeTime(instance.last_played.toISOString()),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import {
|
|||||||
getWorldIdentifier,
|
getWorldIdentifier,
|
||||||
showWorldInFolder,
|
showWorldInFolder,
|
||||||
} from '@/helpers/worlds.ts'
|
} from '@/helpers/worlds.ts'
|
||||||
import { formatNumber } from '@modrinth/utils'
|
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||||
|
import {
|
||||||
|
useRelativeTime,
|
||||||
|
Avatar,
|
||||||
|
ButtonStyled,
|
||||||
|
commonMessages,
|
||||||
|
OverflowMenu,
|
||||||
|
SmartClickable,
|
||||||
|
} from '@modrinth/ui'
|
||||||
import {
|
import {
|
||||||
IssuesIcon,
|
IssuesIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
@@ -25,7 +33,6 @@ import {
|
|||||||
UserIcon,
|
UserIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
|
|
||||||
import type { MessageDescriptor } from '@vintl/vintl'
|
import type { MessageDescriptor } from '@vintl/vintl'
|
||||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
@@ -36,6 +43,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { Tooltip } from 'floating-vue'
|
import { Tooltip } from 'floating-vue'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -100,20 +108,6 @@ const serverIncompatible = computed(
|
|||||||
props.serverStatus.version.protocol !== props.currentProtocol,
|
props.serverStatus.version.protocol !== props.currentProtocol,
|
||||||
)
|
)
|
||||||
|
|
||||||
function getPingLevel(ping: number) {
|
|
||||||
if (ping < 150) {
|
|
||||||
return 5
|
|
||||||
} else if (ping < 300) {
|
|
||||||
return 4
|
|
||||||
} else if (ping < 600) {
|
|
||||||
return 3
|
|
||||||
} else if (ping < 1000) {
|
|
||||||
return 2
|
|
||||||
} else {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@@ -255,7 +249,7 @@ const messages = defineMessages({
|
|||||||
<template v-if="world.last_played">
|
<template v-if="world.last_played">
|
||||||
{{
|
{{
|
||||||
formatMessage(commonMessages.playedLabel, {
|
formatMessage(commonMessages.playedLabel, {
|
||||||
time: dayjs(world.last_played).fromNow(),
|
time: formatRelativeTime(dayjs(world.last_played).toISOString()),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async function saveServer() {
|
|||||||
'server',
|
'server',
|
||||||
address.value,
|
address.value,
|
||||||
newDisplayStatus.value,
|
newDisplayStatus.value,
|
||||||
)
|
).catch(handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function show(world: SingleplayerWorld) {
|
|||||||
icon.value = world.icon
|
icon.value = world.icon
|
||||||
displayStatus.value = world.display_status
|
displayStatus.value = world.display_status
|
||||||
hideFromHome.value = world.display_status === 'hidden'
|
hideFromHome.value = world.display_status === 'hidden'
|
||||||
|
removeIcon.value = false
|
||||||
modal.value.show()
|
modal.value.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
"instance.edit-server.title": {
|
"instance.edit-server.title": {
|
||||||
"message": "Edit server"
|
"message": "Edit server"
|
||||||
},
|
},
|
||||||
|
"instance.edit-world.hide-from-home": {
|
||||||
|
"message": "Hide from the Home page"
|
||||||
|
},
|
||||||
"instance.edit-world.name": {
|
"instance.edit-world.name": {
|
||||||
"message": "Name"
|
"message": "Name"
|
||||||
},
|
},
|
||||||
@@ -362,6 +365,9 @@
|
|||||||
"instance.worlds.copy_address": {
|
"instance.worlds.copy_address": {
|
||||||
"message": "Copy address"
|
"message": "Copy address"
|
||||||
},
|
},
|
||||||
|
"instance.worlds.dont_show_on_home": {
|
||||||
|
"message": "Don't show on Home"
|
||||||
|
},
|
||||||
"instance.worlds.filter.available": {
|
"instance.worlds.filter.available": {
|
||||||
"message": "Available"
|
"message": "Available"
|
||||||
},
|
},
|
||||||
@@ -377,6 +383,9 @@
|
|||||||
"instance.worlds.play_anyway": {
|
"instance.worlds.play_anyway": {
|
||||||
"message": "Play anyway"
|
"message": "Play anyway"
|
||||||
},
|
},
|
||||||
|
"instance.worlds.play_instance": {
|
||||||
|
"message": "Play instance"
|
||||||
|
},
|
||||||
"instance.worlds.type.server": {
|
"instance.worlds.type.server": {
|
||||||
"message": "Server"
|
"message": "Server"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "theseus_playground"
|
name = "theseus_playground"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
theseus = { path = "../../packages/app-lib", features = ["cli"] }
|
theseus = { workspace = true, features = ["cli"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
webbrowser = "0.8.13"
|
enumset.workspace = true
|
||||||
enumset = "1.1"
|
|
||||||
|
|
||||||
tracing = "0.1.37"
|
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
|||||||
println!("A browser window will now open, follow the login flow there.");
|
println!("A browser window will now open, follow the login flow there.");
|
||||||
let login = minecraft_auth::begin_login().await?;
|
let login = minecraft_auth::begin_login().await?;
|
||||||
|
|
||||||
println!("URL {}", login.redirect_uri.as_str());
|
println!("Open URL {} in a browser", login.redirect_uri.as_str());
|
||||||
webbrowser::open(login.redirect_uri.as_str())?;
|
|
||||||
|
|
||||||
println!("Please enter URL code: ");
|
println!("Please enter URL code: ");
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
|
|||||||
@@ -4,60 +4,49 @@ version = "0.9.5"
|
|||||||
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
repository = "https://github.com/modrinth/code/apps/app/"
|
repository = "https://github.com/modrinth/code/apps/app/"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.0.3", features = ["codegen"] }
|
tauri-build = { workspace = true, features = ["codegen"] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
theseus = { path = "../../packages/app-lib", features = ["tauri"] }
|
theseus = { workspace = true, features = ["tauri"] }
|
||||||
|
|
||||||
serde_json = "1.0"
|
serde_json.workspace = true
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_with = "3.0.0"
|
serde_with.workspace = true
|
||||||
|
|
||||||
tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
|
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
|
||||||
tauri-plugin-window-state = "2.2.0"
|
tauri-plugin-window-state.workspace = true
|
||||||
tauri-plugin-deep-link = "2.2.0"
|
tauri-plugin-deep-link.workspace = true
|
||||||
tauri-plugin-os = "2.2.0"
|
tauri-plugin-os.workspace = true
|
||||||
tauri-plugin-opener = "2.2.1"
|
tauri-plugin-opener.workspace = true
|
||||||
tauri-plugin-dialog = "2.2.0"
|
tauri-plugin-dialog.workspace = true
|
||||||
tauri-plugin-updater = { version = "2.3.0" }
|
tauri-plugin-updater.workspace = true
|
||||||
tauri-plugin-single-instance = { version = "2.2.0" }
|
tauri-plugin-single-instance.workspace = true
|
||||||
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { workspace = true, features = ["time"] }
|
||||||
thiserror = "1.0"
|
thiserror.workspace = true
|
||||||
daedalus = { path = "../../packages/daedalus" }
|
daedalus.workspace = true
|
||||||
chrono = "0.4.26"
|
chrono.workspace = true
|
||||||
either = "1.15"
|
either.workspace = true
|
||||||
|
|
||||||
url = "2.2"
|
url.workspace = true
|
||||||
urlencoding = "2.1"
|
urlencoding.workspace = true
|
||||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
uuid = { workspace = true, features = ["serde", "v4"] }
|
||||||
os_info = "3.7.0"
|
|
||||||
|
|
||||||
tracing = "0.1.37"
|
tracing.workspace = true
|
||||||
tracing-error = "0.2.0"
|
tracing-error.workspace = true
|
||||||
|
|
||||||
dashmap = "6.0.1"
|
dashmap.workspace = true
|
||||||
paste = "1.0.15"
|
paste.workspace = true
|
||||||
enumset = { version = "1.1", features = ["serde"] }
|
enumset = { workspace = true, features = ["serde"] }
|
||||||
|
|
||||||
opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }
|
native-dialog.workspace = true
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[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]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 59 KiB |
@@ -9,7 +9,7 @@
|
|||||||
"fix": "cargo fmt && cargo clippy --fix"
|
"fix": "cargo fmt && cargo clippy --fix"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.1.0"
|
"@tauri-apps/cli": "2.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modrinth/app-frontend": "workspace:*",
|
"@modrinth/app-frontend": "workspace:*",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::Runtime;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use theseus::{
|
use theseus::{
|
||||||
handler,
|
handler,
|
||||||
prelude::{CommandPayload, DirectoryInfo},
|
prelude::{CommandPayload, DirectoryInfo},
|
||||||
@@ -47,57 +49,56 @@ pub enum OS {
|
|||||||
// Create a new HashMap with the same keys
|
// 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
|
// Values provided should not be used directly, as they are not guaranteed to be up-to-date
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn progress_bars_list(
|
pub async fn progress_bars_list()
|
||||||
) -> Result<DashMap<uuid::Uuid, theseus::LoadingBar>> {
|
-> Result<DashMap<uuid::Uuid, theseus::LoadingBar>> {
|
||||||
let res = theseus::EventState::list_progress_bars().await?;
|
let res = theseus::EventState::list_progress_bars().await?;
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cfg only on mac os
|
|
||||||
// disables mouseover and fixes a random crash error only fixed by recent versions of macos
|
// disables mouseover and fixes a random crash error only fixed by recent versions of macos
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn should_disable_mouseover() -> bool {
|
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
|
if cfg!(target_os = "macos") {
|
||||||
let os = os_info::get();
|
// 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 os_info::Version::Semantic(major, minor, _) = os.version() {
|
if let tauri_plugin_os::Version::Semantic(major, minor, _) =
|
||||||
if *major >= 12 && *minor >= 3 {
|
tauri_plugin_os::version()
|
||||||
// Mac os version is 12.3 or higher, we allow mouseover
|
{
|
||||||
return false;
|
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]
|
#[tauri::command]
|
||||||
pub fn highlight_in_folder(path: PathBuf) {
|
pub fn highlight_in_folder<R: Runtime>(
|
||||||
let res = opener::reveal(path);
|
app: tauri::AppHandle<R>,
|
||||||
|
path: PathBuf,
|
||||||
if let Err(e) = res {
|
) {
|
||||||
|
if let Err(e) = app.opener().reveal_item_in_dir(path) {
|
||||||
tracing::error!("Failed to highlight file in folder: {}", e);
|
tracing::error!("Failed to highlight file in folder: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn open_path(path: PathBuf) {
|
pub fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) {
|
||||||
let res = opener::open(path);
|
if let Err(e) = app.opener().open_path(path.to_string_lossy(), None::<&str>)
|
||||||
|
{
|
||||||
if let Err(e) = res {
|
|
||||||
tracing::error!("Failed to open path: {}", e);
|
tracing::error!("Failed to open path: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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();
|
let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default();
|
||||||
// failure to get folder just opens filesystem
|
// failure to get folder just opens filesystem
|
||||||
// (ie: if in debug mode only and launcher_logs never created)
|
// (ie: if in debug mode only and launcher_logs never created)
|
||||||
open_path(path);
|
open_path(app, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get opening command
|
// Get opening command
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use either::Either;
|
|||||||
use enumset::EnumSet;
|
use enumset::EnumSet;
|
||||||
use tauri::{AppHandle, Manager, Runtime};
|
use tauri::{AppHandle, Manager, Runtime};
|
||||||
use theseus::prelude::ProcessMetadata;
|
use theseus::prelude::ProcessMetadata;
|
||||||
use theseus::profile::{get_full_path, QuickPlayType};
|
use theseus::profile::{QuickPlayType, get_full_path};
|
||||||
use theseus::worlds::{
|
use theseus::worlds::{
|
||||||
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
|
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
|
||||||
WorldWithProfile,
|
WorldWithProfile,
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
pub mod deep_link;
|
pub mod deep_link;
|
||||||
pub mod window_ext;
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
use native_dialog::{MessageDialog, MessageType};
|
use native_dialog::{DialogBuilder, MessageLevel};
|
||||||
use std::env;
|
use std::env;
|
||||||
use tauri::{Listener, Manager};
|
use tauri::{Listener, Manager};
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
@@ -14,14 +14,6 @@ mod error;
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod 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
|
// Should be called in launcher initialization
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -113,13 +105,14 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
|||||||
fn show_window(app: tauri::AppHandle) {
|
fn show_window(app: tauri::AppHandle) {
|
||||||
let win = app.get_window("main").unwrap();
|
let win = app.get_window("main").unwrap();
|
||||||
if let Err(e) = win.show() {
|
if let Err(e) = win.show() {
|
||||||
MessageDialog::new()
|
DialogBuilder::message()
|
||||||
.set_type(MessageType::Error)
|
.set_level(MessageLevel::Error)
|
||||||
.set_title("Initialization error")
|
.set_title("Initialization error")
|
||||||
.set_text(&format!(
|
.set_text(format!(
|
||||||
"Cannot display application window due to an error:\n{e}"
|
"Cannot display application window due to an error:\n{e}"
|
||||||
))
|
))
|
||||||
.show_alert()
|
.alert()
|
||||||
|
.show()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
panic!("cannot display application window")
|
panic!("cannot display application window")
|
||||||
} else {
|
} else {
|
||||||
@@ -240,9 +233,9 @@ fn main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
{
|
if let Some(window) = app.get_window("main") {
|
||||||
if let Some(window) = app.get_window("main") {
|
if let Err(e) = window.set_shadow(true) {
|
||||||
window.set_shadow(true).unwrap();
|
tracing::warn!("Failed to set window shadow: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,11 +268,6 @@ fn main() {
|
|||||||
restart_app,
|
restart_app,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
builder = builder.plugin(macos::window_ext::init());
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Initializing app...");
|
tracing::info!("Initializing app...");
|
||||||
let app = builder.build(tauri::generate_context!());
|
let app = builder.build(tauri::generate_context!());
|
||||||
|
|
||||||
@@ -321,24 +309,26 @@ fn main() {
|
|||||||
if format!("{e:?}").contains(
|
if format!("{e:?}").contains(
|
||||||
"Runtime(CreateWebview(WebView2Error(WindowsError",
|
"Runtime(CreateWebview(WebView2Error(WindowsError",
|
||||||
) {
|
) {
|
||||||
MessageDialog::new()
|
DialogBuilder::message()
|
||||||
.set_type(MessageType::Error)
|
.set_level(MessageLevel::Error)
|
||||||
.set_title("Initialization 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")
|
.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();
|
.unwrap();
|
||||||
|
|
||||||
panic!("webview2 initialization failed")
|
panic!("webview2 initialization failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageDialog::new()
|
DialogBuilder::message()
|
||||||
.set_type(MessageType::Error)
|
.set_level(MessageLevel::Error)
|
||||||
.set_title("Initialization error")
|
.set_title("Initialization error")
|
||||||
.set_text(&format!(
|
.set_text(format!(
|
||||||
"Cannot initialize application due to an error:\n{e:?}"
|
"Cannot initialize application due to an error:\n{e:?}"
|
||||||
))
|
))
|
||||||
.show_alert()
|
.alert()
|
||||||
|
.show()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
tracing::error!("Error while running tauri application: {:?}", e);
|
tracing::error!("Error while running tauri application: {:?}", e);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm turbo run dev --filter=@modrinth/app-frontend",
|
"beforeDevCommand": "pnpm turbo run dev --filter=@modrinth/app-frontend",
|
||||||
"beforeBuildCommand": "pnpm turbo run build --filter=@modrinth/app-frontend",
|
"beforeBuildCommand": "pnpm turbo run build --filter=@modrinth/app-frontend",
|
||||||
@@ -76,7 +77,14 @@
|
|||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"assetProtocol": {
|
"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
|
"enable": true
|
||||||
},
|
},
|
||||||
"capabilities": ["ads", "core", "plugins"],
|
"capabilities": ["ads", "core", "plugins"],
|
||||||
@@ -87,7 +95,8 @@
|
|||||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
|
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
|
||||||
"style-src": "'unsafe-inline' 'self'",
|
"style-src": "'unsafe-inline' 'self'",
|
||||||
"script-src": "https://*.posthog.com '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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@
|
|||||||
"minWidth": 1100,
|
"minWidth": 1100,
|
||||||
"visible": false,
|
"visible": false,
|
||||||
"zoomHotkeysEnabled": false,
|
"zoomHotkeysEnabled": false,
|
||||||
"decorations": true
|
"decorations": true,
|
||||||
|
"trafficLightPosition": {
|
||||||
|
"x": 15.0,
|
||||||
|
"y": 22.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,29 @@
|
|||||||
name = "daedalus_client"
|
name = "daedalus_client"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
authors = ["Jai A <jai@modrinth.com>"]
|
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
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
daedalus = { path = "../../packages/daedalus" }
|
daedalus.workspace = true
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] }
|
||||||
futures = "0.3.25"
|
futures.workspace = true
|
||||||
dotenvy = "0.15.6"
|
dotenvy.workspace = true
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json.workspace = true
|
||||||
serde-xml-rs = "0.6.0"
|
serde-xml-rs.workspace = true
|
||||||
lazy_static = "1.4.0"
|
thiserror.workspace = true
|
||||||
thiserror = "1.0"
|
reqwest = { workspace = true, features = ["stream", "json", "rustls-tls-native-roots"] }
|
||||||
reqwest = { version = "0.12.5", default-features = false, features = [
|
async_zip = { workspace = true, features = ["chrono", "tokio-fs", "deflate", "bzip2", "zstd", "deflate64"] }
|
||||||
"stream",
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
"json",
|
bytes.workspace = true
|
||||||
"rustls-tls-native-roots",
|
rust-s3.workspace = true
|
||||||
] }
|
dashmap.workspace = true
|
||||||
async_zip = { version = "0.0.17", features = ["full"] }
|
sha1_smol.workspace = true
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
indexmap = { workspace = true, features = ["serde"] }
|
||||||
bytes = "1.6.0"
|
itertools.workspace = true
|
||||||
rust-s3 = { version = "0.33.0", default-features = false, features = [
|
tracing-error.workspace = true
|
||||||
"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"
|
|
||||||
|
|
||||||
tracing = "0.1"
|
tracing.workspace = true
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1.82.0 as build
|
FROM rust:1.86.0 AS build
|
||||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||||
|
|
||||||
WORKDIR /usr/src/daedalus
|
WORKDIR /usr/src/daedalus
|
||||||
@@ -9,9 +9,9 @@ RUN cargo build --release --package daedalus_client
|
|||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN update-ca-certificates
|
RUN update-ca-certificates
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ pub enum ErrorKind {
|
|||||||
SerdeJSON(#[from] serde_json::Error),
|
SerdeJSON(#[from] serde_json::Error),
|
||||||
#[error("Error while deserializing XML: {0}")]
|
#[error("Error while deserializing XML: {0}")]
|
||||||
SerdeXML(#[from] serde_xml_rs::Error),
|
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 {
|
ChecksumFailure {
|
||||||
hash: String,
|
hash: String,
|
||||||
url: String,
|
url: String,
|
||||||
@@ -22,7 +24,7 @@ pub enum ErrorKind {
|
|||||||
Fetch { inner: reqwest::Error, item: String },
|
Fetch { inner: reqwest::Error, item: String },
|
||||||
#[error("Error while uploading file to S3: {file}")]
|
#[error("Error while uploading file to S3: {file}")]
|
||||||
S3 {
|
S3 {
|
||||||
inner: s3::error::S3Error,
|
inner: Box<s3::error::S3Error>,
|
||||||
file: String,
|
file: String,
|
||||||
},
|
},
|
||||||
#[error("Error acquiring semaphore: {0}")]
|
#[error("Error acquiring semaphore: {0}")]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::util::{download_file, fetch_json, format_url};
|
use crate::util::{download_file, fetch_json, format_url};
|
||||||
use crate::{insert_mirrored_artifact, Error, MirrorArtifact, UploadFile};
|
use crate::{Error, MirrorArtifact, UploadFile, insert_mirrored_artifact};
|
||||||
use daedalus::modded::{Manifest, PartialVersionInfo, DUMMY_REPLACE_STRING};
|
use daedalus::modded::{DUMMY_REPLACE_STRING, Manifest, PartialVersionInfo};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -169,10 +169,11 @@ async fn fetch(
|
|||||||
insert_mirrored_artifact(
|
insert_mirrored_artifact(
|
||||||
&new_name,
|
&new_name,
|
||||||
None,
|
None,
|
||||||
vec![lib
|
vec![
|
||||||
.url
|
lib.url
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| maven_url.to_string())],
|
.unwrap_or_else(|| maven_url.to_string()),
|
||||||
|
],
|
||||||
false,
|
false,
|
||||||
mirror_artifacts,
|
mirror_artifacts,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::util::{download_file, fetch_json, fetch_xml, format_url};
|
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 chrono::{DateTime, Utc};
|
||||||
use daedalus::get_path_from_artifact;
|
use daedalus::get_path_from_artifact;
|
||||||
use daedalus::modded::PartialVersionInfo;
|
use daedalus::modded::PartialVersionInfo;
|
||||||
@@ -7,8 +7,8 @@ use dashmap::DashMap;
|
|||||||
use futures::io::Cursor;
|
use futures::io::Cursor;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
@@ -589,14 +589,16 @@ async fn fetch(
|
|||||||
mod_loader: &str,
|
mod_loader: &str,
|
||||||
version: &ForgeVersion,
|
version: &ForgeVersion,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
let extract_file =
|
let extract_file = read_file(
|
||||||
read_file(zip, &value[1..value.len()])
|
zip,
|
||||||
.await?
|
&value[1..value.len()],
|
||||||
.ok_or_else(|| {
|
)
|
||||||
crate::ErrorKind::InvalidInput(format!(
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::ErrorKind::InvalidInput(format!(
|
||||||
"Unable reading data key {key} at path {value}",
|
"Unable reading data key {key} at path {value}",
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let file_name = value.split('/').next_back()
|
let file_name = value.split('/').next_back()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -622,10 +624,7 @@ async fn fetch(
|
|||||||
|
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"com.modrinth.daedalus:{}-installer-extracts:{}:{}@{}",
|
"com.modrinth.daedalus:{}-installer-extracts:{}:{}@{}",
|
||||||
mod_loader,
|
mod_loader, version.raw, file_name, ext
|
||||||
version.raw,
|
|
||||||
file_name,
|
|
||||||
ext
|
|
||||||
);
|
);
|
||||||
|
|
||||||
upload_files.insert(
|
upload_files.insert(
|
||||||
@@ -753,7 +752,8 @@ async fn fetch(
|
|||||||
.rev()
|
.rev()
|
||||||
.chunk_by(|x| x.game_version.clone())
|
.chunk_by(|x| x.game_version.clone())
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(game_version, loaders)| daedalus::modded::Version {
|
.map(|(game_version, loaders)| {
|
||||||
|
daedalus::modded::Version {
|
||||||
id: game_version,
|
id: game_version,
|
||||||
stable: true,
|
stable: true,
|
||||||
loaders: loaders
|
loaders: loaders
|
||||||
@@ -766,6 +766,7 @@ async fn fetch(
|
|||||||
stable: false,
|
stable: false,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use crate::util::{
|
use crate::util::{
|
||||||
format_url, upload_file_to_bucket, upload_url_to_bucket_mirrors,
|
REQWEST_CLIENT, format_url, upload_file_to_bucket,
|
||||||
REQWEST_CLIENT,
|
upload_url_to_bucket_mirrors,
|
||||||
};
|
};
|
||||||
use daedalus::get_path_from_artifact;
|
use daedalus::get_path_from_artifact;
|
||||||
use dashmap::{DashMap, DashSet};
|
use dashmap::{DashMap, DashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use tracing_error::ErrorLayer;
|
use tracing_error::ErrorLayer;
|
||||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
mod fabric;
|
mod fabric;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use crate::util::fetch_json;
|
use crate::util::fetch_json;
|
||||||
use crate::{
|
use crate::{
|
||||||
util::download_file, util::format_url, util::sha1_async, Error,
|
Error, MirrorArtifact, UploadFile, util::download_file, util::format_url,
|
||||||
MirrorArtifact, UploadFile,
|
util::sha1_async,
|
||||||
};
|
};
|
||||||
use daedalus::minecraft::{
|
use daedalus::minecraft::{
|
||||||
merge_partial_library, Library, PartialLibrary, VersionInfo,
|
Library, PartialLibrary, VERSION_MANIFEST_URL, VersionInfo,
|
||||||
VersionManifest, VERSION_MANIFEST_URL,
|
VersionManifest, merge_partial_library,
|
||||||
};
|
};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|||||||
@@ -3,59 +3,57 @@ use bytes::Bytes;
|
|||||||
use s3::creds::Credentials;
|
use s3::creds::Credentials;
|
||||||
use s3::{Bucket, Region};
|
use s3::{Bucket, Region};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, LazyLock};
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
lazy_static::lazy_static! {
|
static BUCKET: LazyLock<Bucket> = LazyLock::new(|| {
|
||||||
static ref BUCKET : Bucket = {
|
let region = dotenvy::var("S3_REGION").unwrap();
|
||||||
let region = dotenvy::var("S3_REGION").unwrap();
|
let b = Bucket::new(
|
||||||
let b = Bucket::new(
|
&dotenvy::var("S3_BUCKET_NAME").unwrap(),
|
||||||
&dotenvy::var("S3_BUCKET_NAME").unwrap(),
|
if &*region == "r2" {
|
||||||
if &*region == "r2" {
|
Region::R2 {
|
||||||
Region::R2 {
|
account_id: dotenvy::var("S3_URL").unwrap(),
|
||||||
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()
|
|
||||||
} else {
|
} 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! {
|
if region == "path-style" {
|
||||||
pub static ref REQWEST_CLIENT: reqwest::Client = {
|
*b.with_path_style()
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
} else {
|
||||||
if let Ok(header) = reqwest::header::HeaderValue::from_str(&format!(
|
*b
|
||||||
"modrinth/daedalus/{} (support@modrinth.com)",
|
}
|
||||||
env!("CARGO_PKG_VERSION")
|
});
|
||||||
)) {
|
|
||||||
headers.insert(reqwest::header::USER_AGENT, header);
|
|
||||||
}
|
|
||||||
|
|
||||||
reqwest::Client::builder()
|
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||||
.tcp_keepalive(Some(std::time::Duration::from_secs(10)))
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
.timeout(std::time::Duration::from_secs(15))
|
if let Ok(header) = reqwest::header::HeaderValue::from_str(&format!(
|
||||||
.default_headers(headers)
|
"modrinth/daedalus/{} (support@modrinth.com)",
|
||||||
.build()
|
env!("CARGO_PKG_VERSION")
|
||||||
.unwrap()
|
)) {
|
||||||
};
|
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))]
|
#[tracing::instrument(skip(bytes, semaphore))]
|
||||||
pub async fn upload_file_to_bucket(
|
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
|
BUCKET.put_object(key.clone(), &bytes).await
|
||||||
}
|
}
|
||||||
.map_err(|err| ErrorKind::S3 {
|
.map_err(|err| ErrorKind::S3 {
|
||||||
inner: err,
|
inner: Box::new(err),
|
||||||
file: path.clone(),
|
file: path.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,7 +201,7 @@ pub async fn download_file(
|
|||||||
inner: err,
|
inner: err,
|
||||||
item: url.to_string(),
|
item: url.to_string(),
|
||||||
}
|
}
|
||||||
.into())
|
.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
[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
|
```bash
|
||||||
cargo install --git https://github.com/launchbadge/sqlx sqlx-cli --no-default-features --features postgres,rustls
|
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
|
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.
|
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>
|
<details>
|
||||||
<summary>.env variables & command line options</summary>
|
<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
|
`MEILISEARCH_KEY`: The name that MeiliSearch is given
|
||||||
`BIND_ADDR`: The bind address for the server. Supports both IPv4 and IPv6
|
`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
|
`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
|
#### CDN options
|
||||||
|
|
||||||
|
|||||||
5
apps/frontend/.env.local
Normal 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
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
BASE_URL=https://api.modrinth.com/v2/
|
BASE_URL=https://api.modrinth.com/v2/
|
||||||
BROWSER_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
|
||||||
4
apps/frontend/.env.staging
Normal 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
|
||||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 734 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -162,6 +162,18 @@ html {
|
|||||||
--landing-green-label-bg: rgba(0, 216, 69, 0.15);
|
--landing-green-label-bg: rgba(0, 216, 69, 0.15);
|
||||||
|
|
||||||
--landing-raw-bg: #fff;
|
--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,
|
.dark,
|
||||||
@@ -286,6 +298,18 @@ html {
|
|||||||
|
|
||||||
--hover-filter: brightness(120%);
|
--hover-filter: brightness(120%);
|
||||||
--active-filter: brightness(140%);
|
--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 {
|
.oled-mode {
|
||||||
|
|||||||
@@ -256,7 +256,9 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="options input-group">
|
<div class="options input-group">
|
||||||
<button
|
<button
|
||||||
v-for="(option, index) in steps[currentStepIndex].options"
|
v-for="(option, index) in steps[currentStepIndex].options.filter(
|
||||||
|
(x) => x.shown !== false,
|
||||||
|
)"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="btn"
|
class="btn"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -426,6 +428,18 @@ const steps = computed(() =>
|
|||||||
resultingMessage: `## Misuse of Title
|
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.`,
|
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.`,
|
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",
|
id: "gallery",
|
||||||
navigate: `/${props.project.project_type}/${props.project.slug}/gallery`,
|
navigate: `/${props.project.project_type}/${props.project.slug}/gallery`,
|
||||||
question: `Are the project's gallery images relevant?`,
|
question: `Are this project's gallery images sufficient?`,
|
||||||
shown: props.project.gallery.length > 0,
|
shown: true,
|
||||||
options: [
|
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",
|
name: "Not relevant",
|
||||||
|
shown: props.project.gallery.length > 0,
|
||||||
resultingMessage: `## Unrelated Gallery Images
|
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.`,
|
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.`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -184,7 +184,7 @@
|
|||||||
"
|
"
|
||||||
class="date"
|
class="date"
|
||||||
>
|
>
|
||||||
{{ fromNow(notif.extra_data.version.date_published) }}
|
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="inline-flex"
|
class="inline-flex"
|
||||||
>
|
>
|
||||||
<CalendarIcon class="mr-1" /> Received {{ fromNow(notification.created) }}
|
<CalendarIcon class="mr-1" /> Received {{ formatRelativeTime(notification.created) }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div v-if="compact" class="notification__actions">
|
<div v-if="compact" class="notification__actions">
|
||||||
@@ -331,11 +331,12 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
ExternalIcon,
|
ExternalIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
|
import { useRelativeTime } from "@modrinth/ui";
|
||||||
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
||||||
import { getProjectLink, getVersionLink } from "~/helpers/projects.js";
|
import { getProjectLink, getVersionLink } from "~/helpers/projects.js";
|
||||||
import { getUserLink } from "~/helpers/users.js";
|
import { getUserLink } from "~/helpers/users.js";
|
||||||
import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.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 DoubleIcon from "~/components/ui/DoubleIcon.vue";
|
||||||
import Avatar from "~/components/ui/Avatar.vue";
|
import Avatar from "~/components/ui/Avatar.vue";
|
||||||
import Badge from "~/components/ui/Badge.vue";
|
import Badge from "~/components/ui/Badge.vue";
|
||||||
@@ -345,6 +346,8 @@ import Categories from "~/components/ui/search/Categories.vue";
|
|||||||
const app = useNuxtApp();
|
const app = useNuxtApp();
|
||||||
const emit = defineEmits(["update:notifications"]);
|
const emit = defineEmits(["update:notifications"]);
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
notification: {
|
notification: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -1,85 +1,140 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="vue-notification-group">
|
<div class="vue-notification-group experimental-styles-within">
|
||||||
<transition-group name="notifs">
|
<transition-group name="notifs">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in notifications"
|
v-for="(item, index) in notifications"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="vue-notification-wrapper"
|
class="vue-notification-wrapper"
|
||||||
@click="notifications.splice(index, 1)"
|
|
||||||
@mouseenter="stopTimer(item)"
|
@mouseenter="stopTimer(item)"
|
||||||
@mouseleave="setNotificationTimer(item)"
|
@mouseleave="setNotificationTimer(item)"
|
||||||
>
|
>
|
||||||
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
|
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
|
||||||
<div class="notification-title" v-html="item.title"></div>
|
<div
|
||||||
<div class="notification-content" v-html="item.text"></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>
|
||||||
</div>
|
</div>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
|
import {
|
||||||
|
XCircleIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
CheckIcon,
|
||||||
|
InfoIcon,
|
||||||
|
IssuesIcon,
|
||||||
|
XIcon,
|
||||||
|
CopyIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
|
|
||||||
function stopTimer(notif) {
|
function stopTimer(notif) {
|
||||||
clearTimeout(notif.timer);
|
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>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<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 {
|
.vue-notification-group {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 25px;
|
right: 1.5rem;
|
||||||
bottom: 25px;
|
bottom: 1.5rem;
|
||||||
z-index: 99999999;
|
z-index: 200;
|
||||||
width: 300px;
|
width: 450px;
|
||||||
|
|
||||||
|
@media screen and (max-width: 500px) {
|
||||||
|
width: calc(100% - 0.75rem * 2);
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.vue-notification-wrapper {
|
.vue-notification-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 10px;
|
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 {
|
&:last-child {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -98,10 +153,18 @@ function stopTimer(notif) {
|
|||||||
.notifs-enter-active,
|
.notifs-enter-active,
|
||||||
.notifs-leave-active,
|
.notifs-leave-active,
|
||||||
.notifs-move {
|
.notifs-move {
|
||||||
transition: all 0.5s;
|
transition: all 0.25s ease-in-out;
|
||||||
}
|
}
|
||||||
.notifs-enter-from,
|
.notifs-enter-from,
|
||||||
.notifs-leave-to {
|
.notifs-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notifs-enter-from {
|
||||||
|
transform: translateY(100%) scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifs-leave-to {
|
||||||
|
transform: translateX(100%) scale(0.8);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
128
apps/frontend/src/components/ui/OptionGroup.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<nav
|
||||||
|
ref="scrollContainer"
|
||||||
|
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(option, index) in options"
|
||||||
|
:key="`option-group-${index}`"
|
||||||
|
ref="optionButtons"
|
||||||
|
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
|
||||||
|
:class="{
|
||||||
|
'text-button-textSelected': modelValue === option,
|
||||||
|
'text-primary': modelValue !== option,
|
||||||
|
}"
|
||||||
|
@click="setOption(option)"
|
||||||
|
>
|
||||||
|
<slot :option="option" :selected="modelValue === option" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
|
||||||
|
:style="{
|
||||||
|
left: sliderLeftPx,
|
||||||
|
top: sliderTopPx,
|
||||||
|
right: sliderRightPx,
|
||||||
|
bottom: sliderBottomPx,
|
||||||
|
opacity: initialized ? 1 : 0,
|
||||||
|
}"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
|
||||||
|
const modelValue = defineModel<T>({ required: true });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
options: T[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const scrollContainer = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const sliderLeft = ref(4);
|
||||||
|
const sliderTop = ref(4);
|
||||||
|
const sliderRight = ref(4);
|
||||||
|
const sliderBottom = ref(4);
|
||||||
|
|
||||||
|
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
|
||||||
|
const sliderTopPx = computed(() => `${sliderTop.value}px`);
|
||||||
|
const sliderRightPx = computed(() => `${sliderRight.value}px`);
|
||||||
|
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
|
||||||
|
|
||||||
|
const optionButtons = ref();
|
||||||
|
|
||||||
|
const initialized = ref(false);
|
||||||
|
|
||||||
|
function setOption(option: T) {
|
||||||
|
modelValue.value = option;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(modelValue, () => {
|
||||||
|
startAnimation(props.options.indexOf(modelValue.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
function startAnimation(index: number) {
|
||||||
|
const el = optionButtons.value[index];
|
||||||
|
|
||||||
|
if (!el || !el.offsetParent) return;
|
||||||
|
|
||||||
|
const newValues = {
|
||||||
|
left: el.offsetLeft,
|
||||||
|
top: el.offsetTop,
|
||||||
|
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||||
|
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sliderLeft.value === 4 && sliderRight.value === 4) {
|
||||||
|
sliderLeft.value = newValues.left;
|
||||||
|
sliderRight.value = newValues.right;
|
||||||
|
sliderTop.value = newValues.top;
|
||||||
|
sliderBottom.value = newValues.bottom;
|
||||||
|
} else {
|
||||||
|
const delay = 200;
|
||||||
|
|
||||||
|
if (newValues.left < sliderLeft.value) {
|
||||||
|
sliderLeft.value = newValues.left;
|
||||||
|
setTimeout(() => {
|
||||||
|
sliderRight.value = newValues.right;
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
sliderRight.value = newValues.right;
|
||||||
|
setTimeout(() => {
|
||||||
|
sliderLeft.value = newValues.left;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newValues.top < sliderTop.value) {
|
||||||
|
sliderTop.value = newValues.top;
|
||||||
|
setTimeout(() => {
|
||||||
|
sliderBottom.value = newValues.bottom;
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
sliderBottom.value = newValues.bottom;
|
||||||
|
setTimeout(() => {
|
||||||
|
sliderTop.value = newValues.top;
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialized.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startAnimation(props.options.indexOf(modelValue.value));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.navtabs-transition {
|
||||||
|
transition:
|
||||||
|
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-shadow {
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
class="stat date"
|
class="stat date"
|
||||||
>
|
>
|
||||||
<UpdatedIcon aria-hidden="true" />
|
<UpdatedIcon aria-hidden="true" />
|
||||||
<span class="date-label">Updated </span>{{ fromNow(updatedAt) }}
|
<span class="date-label">Updated </span>{{ formatRelativeTime(updatedAt) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="showCreatedDate"
|
v-else-if="showCreatedDate"
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
class="stat date"
|
class="stat date"
|
||||||
>
|
>
|
||||||
<CalendarIcon aria-hidden="true" />
|
<CalendarIcon aria-hidden="true" />
|
||||||
<span class="date-label">Published </span>{{ fromNow(createdAt) }}
|
<span class="date-label">Published </span>{{ formatRelativeTime(createdAt) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -95,6 +95,7 @@ import Categories from "~/components/ui/search/Categories.vue";
|
|||||||
import Badge from "~/components/ui/Badge.vue";
|
import Badge from "~/components/ui/Badge.vue";
|
||||||
import EnvironmentIndicator from "~/components/ui/EnvironmentIndicator.vue";
|
import EnvironmentIndicator from "~/components/ui/EnvironmentIndicator.vue";
|
||||||
import Avatar from "~/components/ui/Avatar.vue";
|
import Avatar from "~/components/ui/Avatar.vue";
|
||||||
|
import { useRelativeTime } from "@modrinth/ui";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -213,8 +214,9 @@ export default {
|
|||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const tags = useTags();
|
const tags = useTags();
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
|
||||||
return { tags };
|
return { tags, formatRelativeTime };
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
projectTypeDisplay() {
|
projectTypeDisplay() {
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
|
|||||||
props,
|
props,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const returnTopN = 5;
|
const returnTopN = 15;
|
||||||
|
|
||||||
const listEntries = series
|
const listEntries = series
|
||||||
.map((value, index) => [
|
.map((value, index) => [
|
||||||
|
|||||||
@@ -256,11 +256,11 @@
|
|||||||
>
|
>
|
||||||
<div class="country-flag-container">
|
<div class="country-flag-container">
|
||||||
<template v-if="name.toLowerCase() === 'xx' || !name">
|
<template v-if="name.toLowerCase() === 'xx' || !name">
|
||||||
<img
|
<div
|
||||||
src="https://cdn.modrinth.com/placeholder-banner.svg"
|
class="country-flag flex select-none items-center justify-center bg-bg-raised font-extrabold text-secondary"
|
||||||
alt="Placeholder flag"
|
>
|
||||||
class="country-flag"
|
?
|
||||||
/>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<img
|
<img
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="country-text">
|
<div class="country-text">
|
||||||
<strong class="country-name">
|
<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>
|
<template v-else>{{ countryCodeToName(name) }}</template>
|
||||||
</strong>
|
</strong>
|
||||||
<span class="data-point">{{ formatNumber(count) }}</span>
|
<span class="data-point">{{ formatNumber(count) }}</span>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<span> </span>
|
<span> </span>
|
||||||
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
|
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
|
||||||
fromNow(report.created)
|
formatRelativeTime(report.created)
|
||||||
}}</span>
|
}}</span>
|
||||||
<CopyCode v-if="flags.developerMode" :text="report.id" class="report-id" />
|
<CopyCode v-if="flags.developerMode" :text="report.id" class="report-id" />
|
||||||
</div>
|
</div>
|
||||||
@@ -105,11 +105,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
|
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
|
||||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
import { renderHighlightedString } from "~/helpers/highlight.js";
|
||||||
|
import { useRelativeTime } from "@modrinth/ui";
|
||||||
import Avatar from "~/components/ui/Avatar.vue";
|
import Avatar from "~/components/ui/Avatar.vue";
|
||||||
import Badge from "~/components/ui/Badge.vue";
|
import Badge from "~/components/ui/Badge.vue";
|
||||||
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
||||||
import CopyCode from "~/components/ui/CopyCode.vue";
|
import CopyCode from "~/components/ui/CopyCode.vue";
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
report: {
|
report: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
<ButtonStyled circular type="transparent">
|
<ButtonStyled circular type="transparent">
|
||||||
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
||||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||||
|
<template #extract><PackageOpenIcon /> Extract</template>
|
||||||
<template #rename><EditIcon /> Rename</template>
|
<template #rename><EditIcon /> Rename</template>
|
||||||
<template #move><RightArrowIcon /> Move</template>
|
<template #move><RightArrowIcon /> Move</template>
|
||||||
<template #download><DownloadIcon /> Download</template>
|
<template #download><DownloadIcon /> Download</template>
|
||||||
@@ -73,6 +74,8 @@ import {
|
|||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
|
PackageOpenIcon,
|
||||||
|
FileArchiveIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { computed, shallowRef, ref } from "vue";
|
import { computed, shallowRef, ref } from "vue";
|
||||||
import { renderToString } from "vue/server-renderer";
|
import { renderToString } from "vue/server-renderer";
|
||||||
@@ -99,15 +102,14 @@ interface FileItemProps {
|
|||||||
const props = defineProps<FileItemProps>();
|
const props = defineProps<FileItemProps>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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",
|
e: "moveDirectTo",
|
||||||
item: { name: string; type: string; path: string; destination: string },
|
item: { name: string; type: string; path: string; destination: string },
|
||||||
): void;
|
): 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;
|
(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 textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
|
||||||
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
|
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 units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
|
||||||
|
|
||||||
const route = shallowRef(useRoute());
|
const route = shallowRef(useRoute());
|
||||||
@@ -156,7 +159,18 @@ const containerClasses = computed(() => [
|
|||||||
|
|
||||||
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
||||||
|
|
||||||
|
const isZip = computed(() => fileExtension.value === "zip");
|
||||||
|
|
||||||
const menuOptions = computed(() => [
|
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",
|
id: "rename",
|
||||||
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
|
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 (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
|
||||||
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
|
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
|
||||||
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
|
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
|
||||||
|
if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon;
|
||||||
return FileIcon;
|
return FileIcon;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
:size="item.size"
|
:size="item.size"
|
||||||
@delete="$emit('delete', item)"
|
@delete="$emit('delete', item)"
|
||||||
@rename="$emit('rename', item)"
|
@rename="$emit('rename', item)"
|
||||||
|
@extract="$emit('extract', item)"
|
||||||
@download="$emit('download', item)"
|
@download="$emit('download', item)"
|
||||||
@move="$emit('move', item)"
|
@move="$emit('move', item)"
|
||||||
@move-direct-to="$emit('moveDirectTo', $event)"
|
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||||
@@ -49,14 +50,12 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "delete", item: any): void;
|
(
|
||||||
(e: "rename", item: any): void;
|
e: "delete" | "rename" | "download" | "move" | "edit" | "moveDirectTo" | "extract",
|
||||||
(e: "download", item: any): void;
|
item: any,
|
||||||
(e: "move", item: any): void;
|
): void;
|
||||||
(e: "edit", item: any): void;
|
|
||||||
(e: "contextmenu", item: any, x: number, y: number): void;
|
(e: "contextmenu", item: any, x: number, y: number): void;
|
||||||
(e: "loadMore"): void;
|
(e: "loadMore"): void;
|
||||||
(e: "moveDirectTo", item: any): void;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const ITEM_HEIGHT = 61;
|
const ITEM_HEIGHT = 61;
|
||||||
|
|||||||
@@ -117,7 +117,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ButtonStyled type="transparent">
|
<ButtonStyled type="transparent">
|
||||||
<UiServersTeleportOverflowMenu
|
<OverflowMenu
|
||||||
|
:dropdown-id="`create-new-${baseId}`"
|
||||||
position="bottom"
|
position="bottom"
|
||||||
direction="left"
|
direction="left"
|
||||||
aria-label="Create new..."
|
aria-label="Create new..."
|
||||||
@@ -125,6 +126,10 @@
|
|||||||
{ id: 'file', action: () => $emit('create', 'file') },
|
{ id: 'file', action: () => $emit('create', 'file') },
|
||||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||||
{ id: 'upload', action: () => $emit('upload') },
|
{ 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" />
|
<PlusIcon aria-hidden="true" />
|
||||||
@@ -132,7 +137,16 @@
|
|||||||
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
||||||
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||||
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </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>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -140,6 +154,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
|
LinkIcon,
|
||||||
|
CurseForgeIcon,
|
||||||
|
FileArchiveIcon,
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
@@ -150,7 +167,7 @@ import {
|
|||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
FilterIcon,
|
FilterIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
import { ButtonStyled, OverflowMenu } from "@modrinth/ui";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { useIntersectionObserver } from "@vueuse/core";
|
import { useIntersectionObserver } from "@vueuse/core";
|
||||||
|
|
||||||
@@ -158,12 +175,14 @@ const props = defineProps<{
|
|||||||
breadcrumbSegments: string[];
|
breadcrumbSegments: string[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
currentFilter: string;
|
currentFilter: string;
|
||||||
|
baseId: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: "navigate", index: number): void;
|
(e: "navigate", index: number): void;
|
||||||
(e: "create", type: "file" | "directory"): 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: "update:searchQuery", value: string): void;
|
||||||
(e: "filter", type: string): void;
|
(e: "filter", type: string): void;
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,101 +1,105 @@
|
|||||||
<template>
|
<template>
|
||||||
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
<div>
|
||||||
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||||
<div
|
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||||
ref="statusContentRef"
|
<div
|
||||||
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
|
ref="statusContentRef"
|
||||||
>
|
v-bind="$attrs"
|
||||||
<div class="flex items-center justify-between">
|
:class="['flex flex-col p-4 text-sm text-contrast']"
|
||||||
<div class="flex items-center gap-2 font-bold">
|
>
|
||||||
<FolderOpenIcon class="size-4" />
|
<div class="flex items-center justify-between">
|
||||||
<span>
|
<div class="flex items-center gap-2 font-bold">
|
||||||
<span class="capitalize">
|
<FolderOpenIcon class="size-4" />
|
||||||
{{ props.fileType ? props.fileType : "File" }} Uploads
|
<span>
|
||||||
|
<span class="capitalize">
|
||||||
|
{{ props.fileType ? props.fileType : "File" }} uploads
|
||||||
|
</span>
|
||||||
|
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||||
</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>
|
||||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
</div>
|
||||||
<template v-if="item.status === 'completed'">
|
|
||||||
<span>Done</span>
|
<div class="mt-2 space-y-2">
|
||||||
</template>
|
<div
|
||||||
<template v-else-if="item.status === 'error'">
|
v-for="item in uploadQueue"
|
||||||
<span class="text-red">Failed - File already exists</span>
|
:key="item.file.name"
|
||||||
</template>
|
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||||
<template v-else-if="item.status === 'incorrect-type'">
|
>
|
||||||
<span class="text-red">Failed - Incorrect file type</span>
|
<div class="flex flex-1 items-center gap-2 truncate">
|
||||||
</template>
|
<transition-group name="status-icon" mode="out-in">
|
||||||
<template v-else>
|
<UiServersPanelSpinner
|
||||||
<template v-if="item.status === 'uploading'">
|
v-show="item.status === 'uploading'"
|
||||||
<span>{{ item.progress }}%</span>
|
key="spinner"
|
||||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
class="absolute !size-4"
|
||||||
<div
|
/>
|
||||||
class="h-full bg-contrast transition-all duration-200"
|
<CheckCircleIcon
|
||||||
:style="{ width: item.progress + '%' }"
|
v-show="item.status === 'completed'"
|
||||||
/>
|
key="check"
|
||||||
</div>
|
class="absolute size-4 text-green"
|
||||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
/>
|
||||||
<button>Cancel</button>
|
<XCircleIcon
|
||||||
</ButtonStyled>
|
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>
|
||||||
<template v-else-if="item.status === 'cancelled'">
|
<template v-else-if="item.status === 'error'">
|
||||||
<span class="text-red">Cancelled</span>
|
<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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span>{{ item.progress }}%</span>
|
<template v-if="item.status === 'uploading'">
|
||||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
<span>{{ item.progress }}%</span>
|
||||||
<div
|
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||||
class="h-full bg-contrast transition-all duration-200"
|
<div
|
||||||
:style="{ width: item.progress + '%' }"
|
class="h-full bg-contrast transition-all duration-200"
|
||||||
/>
|
:style="{ width: item.progress + '%' }"
|
||||||
</div>
|
/>
|
||||||
|
</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>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</Transition>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
import { ref, computed, watch, nextTick } from "vue";
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
|
import type { FSModule } from "~/composables/pyroServers.ts";
|
||||||
|
|
||||||
interface UploadItem {
|
interface UploadItem {
|
||||||
file: File;
|
file: 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>
|
||||||
@@ -63,6 +63,7 @@ const props = defineProps<{
|
|||||||
loader: string | null;
|
loader: string | null;
|
||||||
loader_version: string | null;
|
loader_version: string | null;
|
||||||
};
|
};
|
||||||
|
ignoreCurrentInstallation?: boolean;
|
||||||
isInstalling?: boolean;
|
isInstalling?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
<div
|
||||||
|
v-if="!initialSetup"
|
||||||
|
class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"
|
||||||
|
>
|
||||||
<div class="flex w-full flex-row items-center justify-between">
|
<div class="flex w-full flex-row items-center justify-between">
|
||||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||||
Erase all data
|
Erase all data
|
||||||
@@ -146,7 +149,10 @@
|
|||||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
<BackupWarning
|
||||||
|
v-if="!initialSetup"
|
||||||
|
:backup-link="`/servers/manage/${props.server?.serverId}/backups`"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex justify-start gap-4">
|
<div class="mt-4 flex justify-start gap-4">
|
||||||
@@ -217,6 +223,7 @@ const props = defineProps<{
|
|||||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
currentLoader: Loaders | undefined;
|
currentLoader: Loaders | undefined;
|
||||||
backupInProgress?: BackupInProgressReason;
|
backupInProgress?: BackupInProgressReason;
|
||||||
|
initialSetup?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -313,7 +320,7 @@ const selectedLoaderVersions = computed<string[]>(() => {
|
|||||||
const loader = selectedLoader.value.toLowerCase();
|
const loader = selectedLoader.value.toLowerCase();
|
||||||
|
|
||||||
if (loader === "paper") {
|
if (loader === "paper") {
|
||||||
return paperVersions.value[selectedMCVersion.value].map((x) => `${x}`) || [];
|
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loader === "purpur") {
|
if (loader === "purpur") {
|
||||||
@@ -456,7 +463,7 @@ const handleReinstall = async () => {
|
|||||||
selectedLoader.value,
|
selectedLoader.value,
|
||||||
selectedMCVersion.value,
|
selectedMCVersion.value,
|
||||||
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
|
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
|
||||||
hardReset.value,
|
props.initialSetup ? true : hardReset.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
emit("reinstall", {
|
emit("reinstall", {
|
||||||
|
|||||||
278
apps/frontend/src/components/ui/servers/ServerInstallation.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<LazyUiServersPlatformVersionSelectModal
|
||||||
|
ref="versionSelectModal"
|
||||||
|
:server="props.server"
|
||||||
|
:current-loader="ignoreCurrentInstallation ? undefined : (data?.loader as Loaders)"
|
||||||
|
:backup-in-progress="backupInProgress"
|
||||||
|
:initial-setup="ignoreCurrentInstallation"
|
||||||
|
@reinstall="emit('reinstall', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LazyUiServersPlatformMrpackModal
|
||||||
|
ref="mrpackModal"
|
||||||
|
:server="props.server"
|
||||||
|
@reinstall="emit('reinstall', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LazyUiServersPlatformChangeModpackVersionModal
|
||||||
|
ref="modpackVersionModal"
|
||||||
|
:server="props.server"
|
||||||
|
:project="data?.project"
|
||||||
|
:versions="Array.isArray(versions) ? versions : []"
|
||||||
|
:current-version="currentVersion"
|
||||||
|
:current-version-id="data?.upstream?.version_id"
|
||||||
|
:server-status="data?.status"
|
||||||
|
@reinstall="emit('reinstall')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex h-full w-full flex-col">
|
||||||
|
<div v-if="data && versions" class="flex w-full flex-col">
|
||||||
|
<div class="card flex flex-col gap-4">
|
||||||
|
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
|
||||||
|
<div class="flex flex-row items-center gap-2">
|
||||||
|
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
|
||||||
|
<div
|
||||||
|
v-if="updateAvailable"
|
||||||
|
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
|
||||||
|
>
|
||||||
|
<span>Update available</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.upstream" class="flex gap-4">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button
|
||||||
|
class="!w-full sm:!w-auto"
|
||||||
|
:disabled="isInstalling"
|
||||||
|
@click="mrpackModal.show()"
|
||||||
|
>
|
||||||
|
<UploadIcon class="size-4" /> Import .mrpack
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<!-- dumb hack to make a button link not a link -->
|
||||||
|
<ButtonStyled>
|
||||||
|
<template v-if="isInstalling">
|
||||||
|
<button :disabled="isInstalling">
|
||||||
|
<TransferIcon class="size-4" />
|
||||||
|
Switch modpack
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
|
||||||
|
<TransferIcon class="size-4" />
|
||||||
|
Switch modpack
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.upstream" class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-if="versionsError || currentVersionError"
|
||||||
|
class="rounded-2xl border border-solid border-red p-4 text-contrast"
|
||||||
|
>
|
||||||
|
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
|
||||||
|
<p class="m-0 mb-2 mt-1 text-sm">
|
||||||
|
{{ versionsError || currentVersionError }}
|
||||||
|
</p>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button :disabled="isInstalling" @click="refreshData">Retry</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewProjectCard
|
||||||
|
v-if="!versionsError && !currentVersionError"
|
||||||
|
class="!cursor-default !bg-bg !filter-none"
|
||||||
|
:project="projectCardData"
|
||||||
|
:categories="data.project?.categories || []"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
|
||||||
|
<SettingsIcon class="size-4" />
|
||||||
|
Change version
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
</NewProjectCard>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
|
||||||
|
<ButtonStyled>
|
||||||
|
<nuxt-link
|
||||||
|
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||||
|
:class="{ disabled: backupInProgress }"
|
||||||
|
class="!w-full sm:!w-auto"
|
||||||
|
:to="`/modpacks?sid=${props.server.serverId}`"
|
||||||
|
>
|
||||||
|
<CompassIcon class="size-4" /> Find a modpack
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
<span class="hidden sm:block">or</span>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button
|
||||||
|
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||||
|
:disabled="!!backupInProgress"
|
||||||
|
class="!w-full sm:!w-auto"
|
||||||
|
@click="mrpackModal.show()"
|
||||||
|
>
|
||||||
|
<UploadIcon class="size-4" /> Upload .mrpack file
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
|
||||||
|
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
|
||||||
|
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
|
||||||
|
<InfoIcon class="hidden sm:block" />
|
||||||
|
<span class="text-sm text-secondary">
|
||||||
|
The current platform was automatically selected based on your modpack.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col gap-1 rounded-2xl"
|
||||||
|
:class="{
|
||||||
|
'pointer-events-none cursor-not-allowed select-none opacity-50':
|
||||||
|
props.server.general?.status === 'installing',
|
||||||
|
}"
|
||||||
|
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
|
||||||
|
>
|
||||||
|
<UiServersLoaderSelector
|
||||||
|
:data="
|
||||||
|
ignoreCurrentInstallation
|
||||||
|
? {
|
||||||
|
loader: null,
|
||||||
|
loader_version: null,
|
||||||
|
}
|
||||||
|
: data
|
||||||
|
"
|
||||||
|
:is-installing="isInstalling"
|
||||||
|
@select-loader="selectLoader"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
|
||||||
|
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
|
||||||
|
import type { Server } from "~/composables/pyroServers";
|
||||||
|
import type { Loaders } from "~/types/servers";
|
||||||
|
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
|
ignoreCurrentInstallation?: boolean;
|
||||||
|
backupInProgress?: BackupInProgressReason;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reinstall: [any?];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isInstalling = computed(() => props.server.general?.status === "installing");
|
||||||
|
|
||||||
|
const versionSelectModal = ref();
|
||||||
|
const mrpackModal = ref();
|
||||||
|
const modpackVersionModal = ref();
|
||||||
|
|
||||||
|
const data = computed(() => props.server.general);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: versions,
|
||||||
|
error: versionsError,
|
||||||
|
refresh: refreshVersions,
|
||||||
|
} = await useAsyncData(
|
||||||
|
`content-loader-versions-${data.value?.upstream?.project_id}`,
|
||||||
|
async () => {
|
||||||
|
if (!data.value?.upstream?.project_id) return [];
|
||||||
|
try {
|
||||||
|
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`);
|
||||||
|
return result || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("couldnt fetch all versions:", e);
|
||||||
|
throw new Error("Failed to load modpack versions.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ default: () => [] },
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: currentVersion,
|
||||||
|
error: currentVersionError,
|
||||||
|
refresh: refreshCurrentVersion,
|
||||||
|
} = await useAsyncData(
|
||||||
|
`content-loader-version-${data.value?.upstream?.version_id}`,
|
||||||
|
async () => {
|
||||||
|
if (!data.value?.upstream?.version_id) return null;
|
||||||
|
try {
|
||||||
|
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`);
|
||||||
|
return result || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("couldnt fetch version:", e);
|
||||||
|
throw new Error("Failed to load modpack version.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ default: () => null },
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectCardData = computed(() => ({
|
||||||
|
icon_url: data.value?.project?.icon_url,
|
||||||
|
title: data.value?.project?.title,
|
||||||
|
description: data.value?.project?.description,
|
||||||
|
downloads: data.value?.project?.downloads,
|
||||||
|
follows: data.value?.project?.followers,
|
||||||
|
// @ts-ignore
|
||||||
|
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectLoader = (loader: string) => {
|
||||||
|
versionSelectModal.value?.show(loader as Loaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
await Promise.all([refreshVersions(), refreshCurrentVersion()]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAvailable = computed(() => {
|
||||||
|
// so sorry
|
||||||
|
// @ts-ignore
|
||||||
|
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const latestVersion = versions.value[0];
|
||||||
|
// @ts-ignore
|
||||||
|
return latestVersion.id !== currentVersion.value.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.server.general?.status,
|
||||||
|
async (newStatus, oldStatus) => {
|
||||||
|
if (oldStatus === "installing" && newStatus === "available") {
|
||||||
|
await Promise.all([
|
||||||
|
refreshVersions(),
|
||||||
|
refreshCurrentVersion(),
|
||||||
|
props.server.refresh(["general"]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stylized-toggle:checked::after {
|
||||||
|
background: var(--color-accent-contrast) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-base:active {
|
||||||
|
scale: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,7 +43,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="min-h-[20px]"></div>
|
<div v-else class="min-h-[20px]"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isConfiguring"
|
||||||
|
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
|
||||||
|
>
|
||||||
|
<SparklesIcon class="size-5 shrink-0" /> New server
|
||||||
|
</div>
|
||||||
<UiServersServerInfoLabels
|
<UiServersServerInfoLabels
|
||||||
|
v-else
|
||||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||||
:show-game-label="showGameLabel"
|
:show-game-label="showGameLabel"
|
||||||
:show-loader-label="showLoaderLabel"
|
:show-loader-label="showLoaderLabel"
|
||||||
@@ -60,15 +67,7 @@
|
|||||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="status === 'suspended' && suspension_reason === 'support'"
|
v-else-if="status === 'suspended'"
|
||||||
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'"
|
|
||||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||||
>
|
>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
@@ -81,7 +80,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronRightIcon, HammerIcon, LockIcon } from "@modrinth/assets";
|
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
|
||||||
import type { Project, Server } from "~/types/servers";
|
import type { Project, Server } from "~/types/servers";
|
||||||
|
|
||||||
const props = defineProps<Partial<Server>>();
|
const props = defineProps<Partial<Server>>();
|
||||||
@@ -114,4 +113,5 @@ if (import.meta.server && projectData.value?.icon_url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
||||||
|
const isConfiguring = computed(() => props.flows?.intro);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -32,68 +32,68 @@
|
|||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
@mouseleave="handleMouseLeave"
|
@mouseleave="handleMouseLeave"
|
||||||
>
|
>
|
||||||
<ButtonStyled
|
<template
|
||||||
v-for="(option, index) in filteredOptions"
|
v-for="(option, index) in filteredOptions"
|
||||||
:key="option.id"
|
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||||
type="transparent"
|
|
||||||
role="menuitem"
|
|
||||||
:color="option.color"
|
|
||||||
>
|
>
|
||||||
<button
|
<div v-if="isDivider(option)" class="h-px w-full bg-button-bg"></div>
|
||||||
v-if="typeof option.action === 'function'"
|
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
||||||
:ref="
|
<button
|
||||||
(el) => {
|
v-if="typeof option.action === 'function'"
|
||||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
: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)' } : {}"
|
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||||
@click="handleItemClick(option, index)"
|
:aria-selected="index === selectedIndex"
|
||||||
@focus="selectedIndex = index"
|
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||||
@mouseover="handleMouseOver(index)"
|
@click="handleItemClick(option, index)"
|
||||||
>
|
@focus="selectedIndex = index"
|
||||||
<slot :name="option.id">{{ option.id }}</slot>
|
@mouseover="handleMouseOver(index)"
|
||||||
</button>
|
>
|
||||||
<nuxt-link
|
<slot :name="option.id">{{ option.id }}</slot>
|
||||||
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
|
</button>
|
||||||
:ref="
|
<nuxt-link
|
||||||
(el) => {
|
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
|
||||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
: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"
|
:to="option.action"
|
||||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||||
@click="handleItemClick(option, index)"
|
:aria-selected="index === selectedIndex"
|
||||||
@focus="selectedIndex = index"
|
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||||
@mouseover="handleMouseOver(index)"
|
@click="handleItemClick(option, index)"
|
||||||
>
|
@focus="selectedIndex = index"
|
||||||
<slot :name="option.id">{{ option.id }}</slot>
|
@mouseover="handleMouseOver(index)"
|
||||||
</nuxt-link>
|
>
|
||||||
<a
|
<slot :name="option.id">{{ option.id }}</slot>
|
||||||
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
|
</nuxt-link>
|
||||||
:ref="
|
<a
|
||||||
(el) => {
|
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
|
||||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
: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"
|
:href="option.action"
|
||||||
:aria-selected="index === selectedIndex"
|
target="_blank"
|
||||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||||
@click="handleItemClick(option, index)"
|
:aria-selected="index === selectedIndex"
|
||||||
@focus="selectedIndex = index"
|
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||||
@mouseover="handleMouseOver(index)"
|
@click="handleItemClick(option, index)"
|
||||||
>
|
@focus="selectedIndex = index"
|
||||||
<slot :name="option.id">{{ option.id }}</slot>
|
@mouseover="handleMouseOver(index)"
|
||||||
</a>
|
>
|
||||||
<span v-else>
|
<slot :name="option.id">{{ option.id }}</slot>
|
||||||
<slot :name="option.id">{{ option.id }}</slot>
|
</a>
|
||||||
</span>
|
<span v-else>
|
||||||
</ButtonStyled>
|
<slot :name="option.id">{{ option.id }}</slot>
|
||||||
|
</span>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -112,9 +112,20 @@ interface Option {
|
|||||||
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
options: Option[];
|
options: Item[];
|
||||||
hoverable?: boolean;
|
hoverable?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -338,7 +349,9 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||||||
case " ":
|
case " ":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (selectedIndex.value >= 0) {
|
if (selectedIndex.value >= 0) {
|
||||||
selectOption(filteredOptions.value[selectedIndex.value]);
|
const option = filteredOptions.value[selectedIndex.value];
|
||||||
|
if (isDivider(option)) break;
|
||||||
|
selectOption(option);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
@@ -361,8 +374,9 @@ const handleKeydown = (event: KeyboardEvent) => {
|
|||||||
default:
|
default:
|
||||||
if (event.key.length === 1) {
|
if (event.key.length === 1) {
|
||||||
typeAheadBuffer.value += event.key.toLowerCase();
|
typeAheadBuffer.value += event.key.toLowerCase();
|
||||||
const matchIndex = filteredOptions.value.findIndex((option) =>
|
const matchIndex = filteredOptions.value.findIndex(
|
||||||
option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
(option) =>
|
||||||
|
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||||
);
|
);
|
||||||
if (matchIndex !== -1) {
|
if (matchIndex !== -1) {
|
||||||
selectedIndex.value = matchIndex;
|
selectedIndex.value = matchIndex;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
import { ButtonStyled, ServersSpecs } from "@modrinth/ui";
|
||||||
import { RightArrowIcon, SparklesIcon, UnknownIcon } from "@modrinth/assets";
|
|
||||||
import type { MessageDescriptor } from "@vintl/vintl";
|
import type { MessageDescriptor } from "@vintl/vintl";
|
||||||
|
import { formatPrice } from "../../../../../../../packages/utils";
|
||||||
|
|
||||||
const { formatMessage } = useVIntl();
|
const { formatMessage, locale } = useVIntl();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "select" | "scroll-to-faq"): void;
|
(e: "select" | "scroll-to-faq"): void;
|
||||||
@@ -18,8 +18,8 @@ const plans: Record<
|
|||||||
accentText: string;
|
accentText: string;
|
||||||
accentBg: string;
|
accentBg: string;
|
||||||
name: MessageDescriptor;
|
name: MessageDescriptor;
|
||||||
symbol: MessageDescriptor;
|
|
||||||
description: MessageDescriptor;
|
description: MessageDescriptor;
|
||||||
|
mostPopular: boolean;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
small: {
|
small: {
|
||||||
@@ -30,15 +30,11 @@ const plans: Record<
|
|||||||
id: "servers.plan.small.name",
|
id: "servers.plan.small.name",
|
||||||
defaultMessage: "Small",
|
defaultMessage: "Small",
|
||||||
}),
|
}),
|
||||||
symbol: defineMessage({
|
|
||||||
id: "servers.plan.small.symbol",
|
|
||||||
defaultMessage: "S",
|
|
||||||
}),
|
|
||||||
description: defineMessage({
|
description: defineMessage({
|
||||||
id: "servers.plan.small.description",
|
id: "servers.plan.small.description",
|
||||||
defaultMessage:
|
defaultMessage: "Perfect for 1–5 friends with a few light mods.",
|
||||||
"Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.",
|
|
||||||
}),
|
}),
|
||||||
|
mostPopular: false,
|
||||||
},
|
},
|
||||||
medium: {
|
medium: {
|
||||||
buttonColor: "green",
|
buttonColor: "green",
|
||||||
@@ -48,14 +44,11 @@ const plans: Record<
|
|||||||
id: "servers.plan.medium.name",
|
id: "servers.plan.medium.name",
|
||||||
defaultMessage: "Medium",
|
defaultMessage: "Medium",
|
||||||
}),
|
}),
|
||||||
symbol: defineMessage({
|
|
||||||
id: "servers.plan.medium.symbol",
|
|
||||||
defaultMessage: "M",
|
|
||||||
}),
|
|
||||||
description: defineMessage({
|
description: defineMessage({
|
||||||
id: "servers.plan.medium.description",
|
id: "servers.plan.medium.description",
|
||||||
defaultMessage: "Great for modded multiplayer and small communities.",
|
defaultMessage: "Great for 6–15 players and multiple mods.",
|
||||||
}),
|
}),
|
||||||
|
mostPopular: true,
|
||||||
},
|
},
|
||||||
large: {
|
large: {
|
||||||
buttonColor: "purple",
|
buttonColor: "purple",
|
||||||
@@ -65,14 +58,11 @@ const plans: Record<
|
|||||||
id: "servers.plan.large.name",
|
id: "servers.plan.large.name",
|
||||||
defaultMessage: "Large",
|
defaultMessage: "Large",
|
||||||
}),
|
}),
|
||||||
symbol: defineMessage({
|
|
||||||
id: "servers.plan.large.symbol",
|
|
||||||
defaultMessage: "L",
|
|
||||||
}),
|
|
||||||
description: defineMessage({
|
description: defineMessage({
|
||||||
id: "servers.plan.large.description",
|
id: "servers.plan.large.description",
|
||||||
defaultMessage: "Ideal for larger communities, modpacks, and heavy modding.",
|
defaultMessage: "Ideal for 15–25 players, modpacks, or heavy modding.",
|
||||||
}),
|
}),
|
||||||
|
mostPopular: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,42 +73,30 @@ const props = defineProps<{
|
|||||||
storage: number;
|
storage: number;
|
||||||
cpus: number;
|
cpus: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
interval: "monthly" | "quarterly" | "yearly";
|
||||||
|
currency: string;
|
||||||
|
isUsa: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const outOfStock = computed(() => {
|
const outOfStock = computed(() => {
|
||||||
return !props.capacity || props.capacity === 0;
|
return !props.capacity || props.capacity === 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const lowStock = computed(() => {
|
const billingMonths = computed(() => {
|
||||||
return !props.capacity || props.capacity < 8;
|
if (props.interval === "yearly") {
|
||||||
});
|
return 12;
|
||||||
|
} else if (props.interval === "quarterly") {
|
||||||
const formattedRam = computed(() => {
|
return 3;
|
||||||
return props.ram / 1024;
|
}
|
||||||
});
|
return 1;
|
||||||
|
|
||||||
const formattedStorage = computed(() => {
|
|
||||||
return props.storage / 1024;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sharedCpus = computed(() => {
|
|
||||||
return props.cpus / 2;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
<li class="relative flex w-full flex-col justify-between">
|
||||||
<div
|
|
||||||
v-if="lowStock"
|
|
||||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl p-4 text-center font-bold"
|
|
||||||
:class="outOfStock ? 'bg-bg-red' : 'bg-bg-orange'"
|
|
||||||
>
|
|
||||||
<template v-if="outOfStock"> Out of stock! </template>
|
|
||||||
<template v-else> Only {{ capacity }} left in stock! </template>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
:style="
|
:style="
|
||||||
plan === 'medium'
|
plans[plan].mostPopular
|
||||||
? {
|
? {
|
||||||
background: `radial-gradient(
|
background: `radial-gradient(
|
||||||
86.12% 101.64% at 95.97% 94.07%,
|
86.12% 101.64% at 95.97% 94.07%,
|
||||||
@@ -131,55 +109,41 @@ const sharedCpus = computed(() => {
|
|||||||
: undefined
|
: undefined
|
||||||
"
|
"
|
||||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||||
:class="{ '!rounded-t-none': lowStock }"
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex flex-row items-center justify-between">
|
<div class="flex flex-row flex-wrap items-center gap-3">
|
||||||
<h1 class="m-0">{{ formatMessage(plans[plan].name) }}</h1>
|
<h1 class="m-0">{{ formatMessage(plans[plan].name) }}</h1>
|
||||||
<div
|
<div
|
||||||
class="grid size-8 place-content-center rounded-full text-xs font-bold"
|
v-if="plans[plan].mostPopular"
|
||||||
:class="`${plans[plan].accentBg} ${plans[plan].accentText}`"
|
class="rounded-full bg-brand-highlight px-2 py-1 text-xs font-bold text-brand"
|
||||||
>
|
>
|
||||||
{{ formatMessage(plans[plan].symbol) }}
|
Most popular
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="m-0">{{ formatMessage(plans[plan].description) }}</p>
|
|
||||||
<div
|
|
||||||
class="flex flex-row flex-wrap items-center gap-2 text-nowrap text-secondary xl:justify-between"
|
|
||||||
>
|
|
||||||
<p class="m-0">{{ formattedRam }} GB RAM</p>
|
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
|
||||||
<p class="m-0">{{ formattedStorage }} GB SSD</p>
|
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
|
||||||
<p class="m-0">{{ sharedCpus }} Shared CPUs</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 text-secondary">
|
|
||||||
<SparklesIcon /> Bursts up to {{ cpus }} CPUs
|
|
||||||
<nuxt-link
|
|
||||||
v-tooltip="
|
|
||||||
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
|
||||||
"
|
|
||||||
to="/servers#cpu-burst"
|
|
||||||
@click="() => emit('scroll-to-faq')"
|
|
||||||
>
|
|
||||||
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
|
||||||
</nuxt-link>
|
|
||||||
</div>
|
|
||||||
<span class="m-0 text-2xl font-bold text-contrast">
|
<span class="m-0 text-2xl font-bold text-contrast">
|
||||||
${{ price / 100 }}<span class="text-lg font-semibold text-secondary">/month</span>
|
{{ formatPrice(locale, price / billingMonths, currency, true) }}
|
||||||
|
{{ isUsa ? "" : currency }}
|
||||||
|
<span class="text-lg font-semibold text-secondary">
|
||||||
|
/ month<template v-if="interval !== 'monthly'">, billed {{ interval }}</template>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<p class="m-0 max-w-[18rem]">{{ formatMessage(plans[plan].description) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
:color="plans[plan].buttonColor"
|
:color="plans[plan].buttonColor"
|
||||||
:type="plan === 'medium' ? 'standard' : 'highlight-colored-text'"
|
:type="plans[plan].mostPopular ? 'standard' : 'highlight-colored-text'"
|
||||||
size="large"
|
size="large"
|
||||||
>
|
>
|
||||||
<span v-if="outOfStock" class="button-like disabled"> Out of Stock </span>
|
<span v-if="outOfStock" class="button-like disabled"> Out of Stock </span>
|
||||||
<button v-else @click="() => emit('select')">
|
<button v-else @click="() => emit('select')">Select plan</button>
|
||||||
Get Started
|
|
||||||
<RightArrowIcon class="shrink-0" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
<ServersSpecs
|
||||||
|
:ram="ram"
|
||||||
|
:storage="storage"
|
||||||
|
:cpus="cpus"
|
||||||
|
:bursting-link="'/servers#cpu-burst'"
|
||||||
|
@click-bursting-link="() => emit('scroll-to-faq')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import dayjs from "dayjs";
|
|||||||
import { ButtonStyled, commonMessages, CopyCode, ServerNotice, TagItem } from "@modrinth/ui";
|
import { ButtonStyled, commonMessages, CopyCode, ServerNotice, TagItem } from "@modrinth/ui";
|
||||||
import { EditIcon, SettingsIcon, TrashIcon } from "@modrinth/assets";
|
import { EditIcon, SettingsIcon, TrashIcon } from "@modrinth/assets";
|
||||||
import { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
import { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||||
|
import { useRelativeTime } from "@modrinth/ui";
|
||||||
import {
|
import {
|
||||||
DISMISSABLE,
|
DISMISSABLE,
|
||||||
getDismissableMetadata,
|
getDismissableMetadata,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
import { useVIntl } from "@vintl/vintl";
|
import { useVIntl } from "@vintl/vintl";
|
||||||
|
|
||||||
const { formatMessage } = useVIntl();
|
const { formatMessage } = useVIntl();
|
||||||
|
const formatRelativeTime = useRelativeTime();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
notice: ServerNoticeType;
|
notice: ServerNoticeType;
|
||||||
@@ -25,7 +27,7 @@ const props = defineProps<{
|
|||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span v-if="notice.announce_at">
|
<span v-if="notice.announce_at">
|
||||||
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
|
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
|
||||||
dayjs(notice.announce_at).fromNow()
|
formatRelativeTime(notice.announce_at)
|
||||||
}})
|
}})
|
||||||
</span>
|
</span>
|
||||||
<template v-else> Never begins </template>
|
<template v-else> Never begins </template>
|
||||||
@@ -35,7 +37,7 @@ const props = defineProps<{
|
|||||||
v-if="notice.expires"
|
v-if="notice.expires"
|
||||||
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
>
|
>
|
||||||
{{ dayjs(notice.expires).fromNow() }}
|
{{ formatRelativeTime(notice.expires) }}
|
||||||
</span>
|
</span>
|
||||||
<template v-else> Never expires </template>
|
<template v-else> Never expires </template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ import {
|
|||||||
ModrinthIcon,
|
ModrinthIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { AutoLink, OverflowMenu } from "@modrinth/ui";
|
import { AutoLink, OverflowMenu, useRelativeTime } from "@modrinth/ui";
|
||||||
import { renderString } from "@modrinth/utils";
|
import { renderString } from "@modrinth/utils";
|
||||||
import Avatar from "~/components/ui/Avatar.vue";
|
import Avatar from "~/components/ui/Avatar.vue";
|
||||||
import Badge from "~/components/ui/Badge.vue";
|
import Badge from "~/components/ui/Badge.vue";
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export const useUserCountry = () =>
|
|
||||||
useState("userCountry", () => {
|
|
||||||
const headers = useRequestHeaders(["cf-ipcountry"]);
|
|
||||||
|
|
||||||
return headers["cf-ipcountry"] ?? "US";
|
|
||||||
});
|
|
||||||
37
apps/frontend/src/composables/country.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
|
// @ts-expect-error - ignore TS not knowing about navigator.userLanguage
|
||||||
|
const lang = navigator.language || navigator.userLanguage || "";
|
||||||
|
const region = lang.split("-")[1];
|
||||||
|
if (region) {
|
||||||
|
country.value = region.toUpperCase();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return country;
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -11,11 +11,13 @@ export const addNotification = (notification) => {
|
|||||||
);
|
);
|
||||||
if (existingNotif) {
|
if (existingNotif) {
|
||||||
setNotificationTimer(existingNotif);
|
setNotificationTimer(existingNotif);
|
||||||
|
existingNotif.count++;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
notification.id = new Date();
|
notification.id = new Date();
|
||||||
|
notification.count = 1;
|
||||||
|
|
||||||
setNotificationTimer(notification);
|
setNotificationTimer(notification);
|
||||||
notifications.value.push(notification);
|
notifications.value.push(notification);
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export async function usePyroFetch<T>(path: string, options: PyroFetchOptions =
|
|||||||
|
|
||||||
const fullUrl = override?.url
|
const fullUrl = override?.url
|
||||||
? `https://${override.url}/${path.replace(/^\//, "")}`
|
? `https://${override.url}/${path.replace(/^\//, "")}`
|
||||||
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
|
: version === 0
|
||||||
|
? `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`
|
||||||
|
: `${base}/v${version}/${path.replace(/^\//, "")}`;
|
||||||
|
|
||||||
type HeadersRecord = Record<string, string>;
|
type HeadersRecord = Record<string, string>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// usePyroServer is a composable that interfaces with the REDACTED API to get data and control the users server
|
// usePyroServer is a composable that interfaces with the REDACTED API to get data and control the users server
|
||||||
import { $fetch, FetchError } from "ofetch";
|
import { $fetch, FetchError } from "ofetch";
|
||||||
import type { ServerNotice } from "@modrinth/utils";
|
import type { ServerNotice } from "@modrinth/utils";
|
||||||
import type { WSBackupState, WSBackupTask } from "~/types/servers.ts";
|
import type { FilesystemOp, FSQueuedOp, WSBackupState, WSBackupTask } from "~/types/servers.ts";
|
||||||
|
import { usePyroFetch } from "~/composables/pyroFetch.ts";
|
||||||
|
|
||||||
interface PyroFetchOptions {
|
interface PyroFetchOptions {
|
||||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
@@ -40,12 +41,19 @@ class PyroServerError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PyroServersFetchError extends Error {
|
type V1ErrorInfo = {
|
||||||
|
context?: string;
|
||||||
|
error: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ServersError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly statusCode?: number,
|
public readonly statusCode?: number,
|
||||||
public readonly originalError?: Error,
|
public readonly originalError?: Error,
|
||||||
public readonly module?: string,
|
public readonly module?: string,
|
||||||
|
public readonly v1Error?: V1ErrorInfo,
|
||||||
) {
|
) {
|
||||||
let errorMessage = message;
|
let errorMessage = message;
|
||||||
let method = "GET";
|
let method = "GET";
|
||||||
@@ -96,17 +104,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>(
|
async function PyroFetch<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: PyroFetchOptions = {},
|
options: PyroFetchOptions = {},
|
||||||
module?: string,
|
module?: string,
|
||||||
|
errorContext?: string,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
const authToken = auth.value?.token;
|
const authToken = auth.value?.token;
|
||||||
|
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
throw new PyroServersFetchError("Missing auth token", 401, undefined, module);
|
throw new ServersError("Missing auth token", 401, undefined, module);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -124,16 +150,18 @@ async function PyroFetch<T>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!base) {
|
if (!base) {
|
||||||
throw new PyroServersFetchError(
|
throw new ServersError("Configuration error: Missing PYRO_BASE_URL", 500, undefined, module);
|
||||||
"Configuration error: Missing PYRO_BASE_URL",
|
|
||||||
500,
|
|
||||||
undefined,
|
|
||||||
module,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullUrl = override?.url
|
const versionString = `v${version}`;
|
||||||
? `https://${override.url}/${path.replace(/^\//, "")}`
|
|
||||||
|
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(/^\//, "")}`;
|
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -170,11 +198,20 @@ async function PyroFetch<T>(
|
|||||||
attempts++;
|
attempts++;
|
||||||
|
|
||||||
if (error instanceof FetchError) {
|
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 statusCode = error.response?.status;
|
||||||
const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
|
const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
|
||||||
|
|
||||||
if (!isRetryable || attempts >= maxAttempts) {
|
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);
|
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
|
||||||
@@ -182,7 +219,7 @@ async function PyroFetch<T>(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new PyroServersFetchError(
|
throw new ServersError(
|
||||||
"Unexpected error during fetch operation",
|
"Unexpected error during fetch operation",
|
||||||
undefined,
|
undefined,
|
||||||
error as Error,
|
error as Error,
|
||||||
@@ -271,10 +308,8 @@ interface General {
|
|||||||
| "moderated"
|
| "moderated"
|
||||||
| "paymentfailed"
|
| "paymentfailed"
|
||||||
| "cancelled"
|
| "cancelled"
|
||||||
| "other"
|
|
||||||
| "transferring"
|
|
||||||
| "upgrading"
|
| "upgrading"
|
||||||
| "support"
|
| "other"
|
||||||
| (string & {});
|
| (string & {});
|
||||||
loader: string;
|
loader: string;
|
||||||
loader_version: string;
|
loader_version: string;
|
||||||
@@ -296,6 +331,9 @@ interface General {
|
|||||||
token: string;
|
token: string;
|
||||||
instance: string;
|
instance: string;
|
||||||
};
|
};
|
||||||
|
flows?: {
|
||||||
|
intro?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Allocation {
|
interface Allocation {
|
||||||
@@ -419,7 +457,7 @@ const processImage = async (iconUrl: string | undefined) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
|
if (error instanceof ServersError && error.statusCode === 404 && iconUrl) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(iconUrl);
|
const response = await fetch(iconUrl);
|
||||||
if (!response.ok) throw new Error("Failed to fetch icon");
|
if (!response.ok) throw new Error("Failed to fetch icon");
|
||||||
@@ -892,7 +930,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
|
|||||||
try {
|
try {
|
||||||
return await requestFn();
|
return await requestFn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
|
if (error instanceof ServersError && error.statusCode === 401) {
|
||||||
await internalServerReference.value.refresh(["fs"]);
|
await internalServerReference.value.refresh(["fs"]);
|
||||||
return await requestFn();
|
return await requestFn();
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1089,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 deleteFileOrFolder = (path: string, recursive: boolean) => {
|
||||||
const encodedPath = encodeURIComponent(path);
|
const encodedPath = encodeURIComponent(path);
|
||||||
return retryWithAuth(async () => {
|
return retryWithAuth(async () => {
|
||||||
@@ -1104,9 +1204,9 @@ const modules: any = {
|
|||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fetchError =
|
const fetchError =
|
||||||
error instanceof PyroServersFetchError
|
error instanceof ServersError
|
||||||
? error
|
? error
|
||||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: "error",
|
status: "error",
|
||||||
@@ -1125,6 +1225,13 @@ const modules: any = {
|
|||||||
suspend: suspendServer,
|
suspend: suspendServer,
|
||||||
getMotd,
|
getMotd,
|
||||||
setMotd,
|
setMotd,
|
||||||
|
endIntro: async () => {
|
||||||
|
await usePyroFetch(`servers/${internalServerReference.value.serverId}/flows/intro`, {
|
||||||
|
method: "DELETE",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
await internalServerReference.value.refresh(["general"]);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
get: async (serverId: string) => {
|
get: async (serverId: string) => {
|
||||||
@@ -1135,9 +1242,9 @@ const modules: any = {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fetchError =
|
const fetchError =
|
||||||
error instanceof PyroServersFetchError
|
error instanceof ServersError
|
||||||
? error
|
? error
|
||||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: [],
|
data: [],
|
||||||
@@ -1160,9 +1267,9 @@ const modules: any = {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fetchError =
|
const fetchError =
|
||||||
error instanceof PyroServersFetchError
|
error instanceof ServersError
|
||||||
? error
|
? error
|
||||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: [],
|
data: [],
|
||||||
@@ -1196,9 +1303,9 @@ const modules: any = {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fetchError =
|
const fetchError =
|
||||||
error instanceof PyroServersFetchError
|
error instanceof ServersError
|
||||||
? error
|
? error
|
||||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allocations: [],
|
allocations: [],
|
||||||
@@ -1221,9 +1328,9 @@ const modules: any = {
|
|||||||
return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
|
return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fetchError =
|
const fetchError =
|
||||||
error instanceof PyroServersFetchError
|
error instanceof ServersError
|
||||||
? error
|
? error
|
||||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
@@ -1241,9 +1348,9 @@ const modules: any = {
|
|||||||
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
|
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fetchError =
|
const fetchError =
|
||||||
error instanceof PyroServersFetchError
|
error instanceof ServersError
|
||||||
? error
|
? error
|
||||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
@@ -1255,14 +1362,16 @@ const modules: any = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
fs: {
|
fs: {
|
||||||
|
queuedOps: [],
|
||||||
|
opsQueuedForModification: [],
|
||||||
get: async (serverId: string) => {
|
get: async (serverId: string) => {
|
||||||
try {
|
try {
|
||||||
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
|
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const fetchError =
|
const fetchError =
|
||||||
error instanceof PyroServersFetchError
|
error instanceof ServersError
|
||||||
? error
|
? error
|
||||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
: new ServersError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
auth: undefined,
|
auth: undefined,
|
||||||
@@ -1281,6 +1390,10 @@ const modules: any = {
|
|||||||
moveFileOrFolder,
|
moveFileOrFolder,
|
||||||
deleteFileOrFolder,
|
deleteFileOrFolder,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
|
extractFile,
|
||||||
|
removeQueuedOp,
|
||||||
|
clearQueuedOps,
|
||||||
|
modifyOp,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1349,6 +1462,8 @@ type GeneralFunctions = {
|
|||||||
* @deprecated Use fs.downloadFile instead
|
* @deprecated Use fs.downloadFile instead
|
||||||
*/
|
*/
|
||||||
fetchConfigFile: (fileName: string) => Promise<any>;
|
fetchConfigFile: (fileName: string) => Promise<any>;
|
||||||
|
|
||||||
|
endIntro: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ContentFunctions = {
|
type ContentFunctions = {
|
||||||
@@ -1588,10 +1703,29 @@ type FSFunctions = {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
downloadFile: (path: string, raw?: boolean) => Promise<any>;
|
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 = {
|
type ModuleError = {
|
||||||
error: PyroServersFetchError;
|
error: ServersError;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1624,8 +1758,11 @@ type WSModule = JWTAuth & {
|
|||||||
error?: ModuleError;
|
error?: ModuleError;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FSModule = {
|
export type FSModule = {
|
||||||
auth: JWTAuth;
|
auth: JWTAuth;
|
||||||
|
ops: FilesystemOp[];
|
||||||
|
queuedOps: FSQueuedOp[];
|
||||||
|
opsQueuedForModification: string[];
|
||||||
error?: ModuleError;
|
error?: ModuleError;
|
||||||
} & FSFunctions;
|
} & FSFunctions;
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,7 @@
|
|||||||
<Logo404 />
|
<Logo404 />
|
||||||
</div>
|
</div>
|
||||||
<div class="error-box" :class="{ 'has-bot': !is404 }">
|
<div class="error-box" :class="{ 'has-bot': !is404 }">
|
||||||
<img
|
<img v-if="!is404" :src="SadRinthbot" alt="Sad Modrinth bot" class="error-box__sad-bot" />
|
||||||
v-if="!is404"
|
|
||||||
src="https://cdn-raw.modrinth.com/sad-bot.webp"
|
|
||||||
alt="Sad Modrinth bot"
|
|
||||||
class="error-box__sad-bot"
|
|
||||||
/>
|
|
||||||
<div v-if="!is404" class="error-box__top-glow" />
|
<div v-if="!is404" class="error-box__top-glow" />
|
||||||
<div class="error-box__body">
|
<div class="error-box__body">
|
||||||
<h1 class="error-box__title">{{ formatMessage(errorMessages.title) }}</h1>
|
<h1 class="error-box__title">{{ formatMessage(errorMessages.title) }}</h1>
|
||||||
@@ -55,6 +50,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineMessage, useVIntl } from "@vintl/vintl";
|
import { defineMessage, useVIntl } from "@vintl/vintl";
|
||||||
|
import { SadRinthbot } from "@modrinth/assets";
|
||||||
import Logo404 from "~/assets/images/404.svg";
|
import Logo404 from "~/assets/images/404.svg";
|
||||||
|
|
||||||
const { formatMessage } = useVIntl();
|
const { formatMessage } = useVIntl();
|
||||||
@@ -81,6 +77,9 @@ const errorMessages = computed(
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
// TODO: REMOVE BEFORE MERGE
|
||||||
|
console.log(props.error);
|
||||||
|
|
||||||
watch(route, () => {
|
watch(route, () => {
|
||||||
console.log(route);
|
console.log(route);
|
||||||
});
|
});
|
||||||
@@ -272,6 +271,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 {
|
.error-box {
|
||||||
background-color: var(--color-raised-bg);
|
background-color: var(--color-raised-bg);
|
||||||
border-radius: 1.25rem;
|
border-radius: 1.25rem;
|
||||||
@@ -281,105 +293,96 @@ const routeMessages = [
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
|
||||||
|
|
||||||
.error-box.has-bot {
|
&.has-bot {
|
||||||
margin-block: 120px;
|
margin-block: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-box p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-box a {
|
a {
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
|
||||||
|
|
||||||
.error-box a:hover,
|
&:hover,
|
||||||
.error-box a:focus {
|
&:focus {
|
||||||
filter: brightness(1.125);
|
filter: brightness(1.125);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.error-graphic {
|
&__sad-bot {
|
||||||
margin-bottom: 2rem;
|
--_bot-height: 112px;
|
||||||
display: flex;
|
position: absolute;
|
||||||
justify-content: center;
|
top: calc(-1 * var(--_bot-height));
|
||||||
}
|
right: 5rem;
|
||||||
|
width: auto;
|
||||||
|
height: var(--_bot-height);
|
||||||
|
|
||||||
.error-graphic svg {
|
@media screen and (max-width: 768px) {
|
||||||
fill: var(--color-text);
|
--_bot-height: 70px;
|
||||||
color: var(--color-text);
|
right: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
width: min(15rem, 100%);
|
&__top-glow {
|
||||||
height: auto;
|
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 {
|
&__title {
|
||||||
--_bot-height: 112px;
|
font-size: 2rem;
|
||||||
position: absolute;
|
font-weight: 900;
|
||||||
top: calc(-1 * var(--_bot-height));
|
margin: 0;
|
||||||
right: 5rem;
|
}
|
||||||
width: auto;
|
|
||||||
height: var(--_bot-height);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-box__top-glow {
|
&__subtitle {
|
||||||
width: 100%;
|
font-size: 1.25rem;
|
||||||
height: 1px;
|
font-weight: 600;
|
||||||
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__title {
|
&__body {
|
||||||
font-size: 2rem;
|
display: flex;
|
||||||
font-weight: 900;
|
flex-direction: column;
|
||||||
margin: 0;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-box__subtitle {
|
&__list-title {
|
||||||
font-size: 1.25rem;
|
font-weight: 600;
|
||||||
font-weight: 600;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.error-box__body {
|
&__list {
|
||||||
display: flex;
|
margin: 0;
|
||||||
flex-direction: column;
|
display: flex;
|
||||||
gap: 0.75rem;
|
flex-direction: column;
|
||||||
}
|
gap: 0.25rem;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.error-box__list-title {
|
li {
|
||||||
font-weight: 600;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-box__list {
|
&__details {
|
||||||
margin: 0;
|
display: flex;
|
||||||
display: flex;
|
flex-direction: column;
|
||||||
flex-direction: column;
|
color: var(--color-secondary);
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding-left: 1.25rem;
|
font-weight: 500;
|
||||||
}
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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 () => {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
185
apps/frontend/src/helpers/notifications.ts
Normal 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 () => [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,76 +27,90 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
|
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
|
||||||
<div
|
<PagewideBanner
|
||||||
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
|
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
|
||||||
class="email-nag"
|
variant="warning"
|
||||||
>
|
>
|
||||||
<template v-if="auth.user.email">
|
<template #title>
|
||||||
<span>{{ formatMessage(verifyEmailBannerMessages.title) }}</span>
|
<span>
|
||||||
<button class="btn" @click="resendVerifyEmail">
|
{{
|
||||||
|
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) }}
|
{{ formatMessage(verifyEmailBannerMessages.action) }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
<nuxt-link v-else class="btn" to="/settings/account">
|
||||||
<template v-else>
|
|
||||||
<span>{{ formatMessage(addEmailBannerMessages.title) }}</span>
|
|
||||||
<nuxt-link class="btn" to="/settings/account">
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
<SettingsIcon aria-hidden="true" />
|
||||||
{{ formatMessage(addEmailBannerMessages.action) }}
|
{{ formatMessage(addEmailBannerMessages.action) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</PagewideBanner>
|
||||||
<div
|
<PagewideBanner
|
||||||
v-if="
|
v-if="
|
||||||
user &&
|
|
||||||
user.subscriptions &&
|
|
||||||
user.subscriptions.some((x) => x.status === 'payment-failed') &&
|
user.subscriptions.some((x) => x.status === 'payment-failed') &&
|
||||||
route.path !== '/settings/billing'
|
route.path !== '/settings/billing'
|
||||||
"
|
"
|
||||||
class="email-nag"
|
variant="error"
|
||||||
>
|
>
|
||||||
<span>{{ formatMessage(subscriptionPaymentFailedBannerMessages.title) }}</span>
|
<template #title>
|
||||||
<nuxt-link class="btn" to="/settings/billing">
|
<span>{{ formatMessage(subscriptionPaymentFailedBannerMessages.title) }}</span>
|
||||||
<SettingsIcon aria-hidden="true" />
|
</template>
|
||||||
{{ formatMessage(subscriptionPaymentFailedBannerMessages.action) }}
|
<template #description>
|
||||||
</nuxt-link>
|
<span>{{ formatMessage(subscriptionPaymentFailedBannerMessages.description) }}</span>
|
||||||
</div>
|
</template>
|
||||||
<div
|
<template #actions>
|
||||||
|
<nuxt-link class="btn" to="/settings/billing">
|
||||||
|
<SettingsIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(subscriptionPaymentFailedBannerMessages.action) }}
|
||||||
|
</nuxt-link>
|
||||||
|
</template>
|
||||||
|
</PagewideBanner>
|
||||||
|
<PagewideBanner
|
||||||
v-if="
|
v-if="
|
||||||
config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com') &&
|
config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com') &&
|
||||||
!cosmetics.hideStagingBanner
|
!cosmetics.hideStagingBanner
|
||||||
"
|
"
|
||||||
class="site-banner site-banner--warning [&>*]:z-[6]"
|
variant="warning"
|
||||||
>
|
>
|
||||||
<div class="site-banner__title">
|
<template #title>
|
||||||
<IssuesIcon aria-hidden="true" />
|
|
||||||
<span>{{ formatMessage(stagingBannerMessages.title) }}</span>
|
<span>{{ formatMessage(stagingBannerMessages.title) }}</span>
|
||||||
</div>
|
</template>
|
||||||
<div class="site-banner__description">
|
<template #description>
|
||||||
{{ formatMessage(stagingBannerMessages.description) }}
|
{{ formatMessage(stagingBannerMessages.description) }}
|
||||||
</div>
|
</template>
|
||||||
<div class="site-banner__actions">
|
<template #actions_right>
|
||||||
<Button transparent icon-only :action="hideStagingBanner" aria-label="Close banner"
|
<Button transparent icon-only aria-label="Close" @click="hideStagingBanner">
|
||||||
><XIcon aria-hidden="true"
|
<XIcon aria-hidden="true" />
|
||||||
/></Button>
|
</Button>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</PagewideBanner>
|
||||||
<div
|
<PagewideBanner v-if="generatedStateErrors?.length" variant="error">
|
||||||
v-if="generatedStateErrors && generatedStateErrors.length > 0"
|
<template #title>
|
||||||
class="site-banner site-banner--warning [&>*]:z-[6]"
|
|
||||||
>
|
|
||||||
<div class="site-banner__title">
|
|
||||||
<IssuesIcon aria-hidden="true" />
|
|
||||||
<span>{{ formatMessage(failedToBuildBannerMessages.title) }}</span>
|
<span>{{ formatMessage(failedToBuildBannerMessages.title) }}</span>
|
||||||
</div>
|
</template>
|
||||||
<div class="site-banner__description">
|
<template #description>
|
||||||
{{
|
{{
|
||||||
formatMessage(failedToBuildBannerMessages.description, {
|
formatMessage(failedToBuildBannerMessages.description, {
|
||||||
errors: generatedStateErrors,
|
errors: generatedStateErrors,
|
||||||
url: config.public.apiBaseUrl,
|
url: config.public.apiBaseUrl,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</PagewideBanner>
|
||||||
<header
|
<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]"
|
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,
|
GitHubIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
} from "@modrinth/assets";
|
} 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 { isAdmin, isStaff } from "@modrinth/utils";
|
||||||
import { errors as generatedStateErrors } from "~/generated/state.json";
|
import { errors as generatedStateErrors } from "~/generated/state.json";
|
||||||
|
|
||||||
@@ -720,8 +741,13 @@ const basePopoutId = useId();
|
|||||||
|
|
||||||
const verifyEmailBannerMessages = defineMessages({
|
const verifyEmailBannerMessages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
id: "layout.banner.verify-email.title",
|
id: "layout.banner.account-action",
|
||||||
defaultMessage: "For security purposes, please verify your email address on Modrinth.",
|
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: {
|
action: {
|
||||||
id: "layout.banner.verify-email.action",
|
id: "layout.banner.verify-email.action",
|
||||||
@@ -731,8 +757,13 @@ const verifyEmailBannerMessages = defineMessages({
|
|||||||
|
|
||||||
const addEmailBannerMessages = defineMessages({
|
const addEmailBannerMessages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
id: "layout.banner.add-email.title",
|
id: "layout.banner.account-action",
|
||||||
defaultMessage: "For security purposes, please enter your email on Modrinth.",
|
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: {
|
action: {
|
||||||
id: "layout.banner.add-email.button",
|
id: "layout.banner.add-email.button",
|
||||||
@@ -743,8 +774,12 @@ const addEmailBannerMessages = defineMessages({
|
|||||||
const subscriptionPaymentFailedBannerMessages = defineMessages({
|
const subscriptionPaymentFailedBannerMessages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
id: "layout.banner.subscription-payment-failed.title",
|
id: "layout.banner.subscription-payment-failed.title",
|
||||||
|
defaultMessage: "Billing action required.",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
id: "layout.banner.subscription-payment-failed.description",
|
||||||
defaultMessage:
|
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: {
|
action: {
|
||||||
id: "layout.banner.subscription-payment-failed.button",
|
id: "layout.banner.subscription-payment-failed.button",
|
||||||
@@ -755,7 +790,7 @@ const subscriptionPaymentFailedBannerMessages = defineMessages({
|
|||||||
const stagingBannerMessages = defineMessages({
|
const stagingBannerMessages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
id: "layout.banner.staging.title",
|
id: "layout.banner.staging.title",
|
||||||
defaultMessage: "You’re viewing Modrinth’s staging environment.",
|
defaultMessage: "You’re viewing Modrinth’s staging environment",
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
id: "layout.banner.staging.description",
|
id: "layout.banner.staging.description",
|
||||||
@@ -1052,7 +1087,6 @@ watch(
|
|||||||
document.body.removeAttribute("tabindex");
|
document.body.removeAttribute("tabindex");
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCurrentDate();
|
|
||||||
runAnalytics();
|
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) {
|
@media (max-width: 1200px) {
|
||||||
.app-btn {
|
.app-btn {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
"message": "Subscribe to updates about Modrinth"
|
"message": "Subscribe to updates about Modrinth"
|
||||||
},
|
},
|
||||||
"auth.welcome.description": {
|
"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": "You’re now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods."
|
||||||
},
|
},
|
||||||
"auth.welcome.label.tos": {
|
"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>."
|
"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,11 +344,14 @@
|
|||||||
"layout.avatar.alt": {
|
"layout.avatar.alt": {
|
||||||
"message": "Your avatar"
|
"message": "Your avatar"
|
||||||
},
|
},
|
||||||
|
"layout.banner.account-action": {
|
||||||
|
"message": "Account action required"
|
||||||
|
},
|
||||||
"layout.banner.add-email.button": {
|
"layout.banner.add-email.button": {
|
||||||
"message": "Visit account settings"
|
"message": "Visit account settings"
|
||||||
},
|
},
|
||||||
"layout.banner.add-email.title": {
|
"layout.banner.add-email.description": {
|
||||||
"message": "For security purposes, please enter your email on Modrinth."
|
"message": "For security reasons, Modrinth needs you to register an email address to your account."
|
||||||
},
|
},
|
||||||
"layout.banner.build-fail.description": {
|
"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}"
|
"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}"
|
||||||
@@ -360,19 +363,22 @@
|
|||||||
"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."
|
"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": {
|
"layout.banner.staging.title": {
|
||||||
"message": "You’re viewing Modrinth’s staging environment."
|
"message": "You’re viewing Modrinth’s staging environment"
|
||||||
},
|
},
|
||||||
"layout.banner.subscription-payment-failed.button": {
|
"layout.banner.subscription-payment-failed.button": {
|
||||||
"message": "Update billing info"
|
"message": "Update billing info"
|
||||||
},
|
},
|
||||||
|
"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.subscription-payment-failed.title": {
|
"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.verify-email.action": {
|
"layout.banner.verify-email.action": {
|
||||||
"message": "Re-send verification email"
|
"message": "Re-send verification email"
|
||||||
},
|
},
|
||||||
"layout.banner.verify-email.title": {
|
"layout.banner.verify-email.description": {
|
||||||
"message": "For security purposes, please verify your email address on Modrinth."
|
"message": "For security reasons, Modrinth needs you to verify the email address associated with your account."
|
||||||
},
|
},
|
||||||
"layout.footer.about": {
|
"layout.footer.about": {
|
||||||
"message": "About"
|
"message": "About"
|
||||||
@@ -1044,32 +1050,23 @@
|
|||||||
"message": "No notices"
|
"message": "No notices"
|
||||||
},
|
},
|
||||||
"servers.plan.large.description": {
|
"servers.plan.large.description": {
|
||||||
"message": "Ideal for larger communities, modpacks, and heavy modding."
|
"message": "Ideal for 15–25 players, modpacks, or heavy modding."
|
||||||
},
|
},
|
||||||
"servers.plan.large.name": {
|
"servers.plan.large.name": {
|
||||||
"message": "Large"
|
"message": "Large"
|
||||||
},
|
},
|
||||||
"servers.plan.large.symbol": {
|
|
||||||
"message": "L"
|
|
||||||
},
|
|
||||||
"servers.plan.medium.description": {
|
"servers.plan.medium.description": {
|
||||||
"message": "Great for modded multiplayer and small communities."
|
"message": "Great for 6–15 players and multiple mods."
|
||||||
},
|
},
|
||||||
"servers.plan.medium.name": {
|
"servers.plan.medium.name": {
|
||||||
"message": "Medium"
|
"message": "Medium"
|
||||||
},
|
},
|
||||||
"servers.plan.medium.symbol": {
|
|
||||||
"message": "M"
|
|
||||||
},
|
|
||||||
"servers.plan.small.description": {
|
"servers.plan.small.description": {
|
||||||
"message": "Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding."
|
"message": "Perfect for 1–5 friends with a few light mods."
|
||||||
},
|
},
|
||||||
"servers.plan.small.name": {
|
"servers.plan.small.name": {
|
||||||
"message": "Small"
|
"message": "Small"
|
||||||
},
|
},
|
||||||
"servers.plan.small.symbol": {
|
|
||||||
"message": "S"
|
|
||||||
},
|
|
||||||
"settings.billing.modal.cancel.action": {
|
"settings.billing.modal.cancel.action": {
|
||||||
"message": "Cancel subscription"
|
"message": "Cancel subscription"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -871,6 +871,7 @@ import {
|
|||||||
ProjectSidebarDetails,
|
ProjectSidebarDetails,
|
||||||
ProjectSidebarLinks,
|
ProjectSidebarLinks,
|
||||||
ScrollablePanel,
|
ScrollablePanel,
|
||||||
|
useRelativeTime,
|
||||||
} from "@modrinth/ui";
|
} from "@modrinth/ui";
|
||||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
This is a private conversation thread with the Modrinth moderators. They may message you
|
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
|
with issues concerning this project. This thread is only checked when you submit your
|
||||||
project for review. For additional inquiries, contact
|
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>
|
</p>
|
||||||
<ConversationThread
|
<ConversationThread
|
||||||
v-if="thread"
|
v-if="thread"
|
||||||
|
|||||||