Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f73bc89f84 | ||
|
|
4f220201b3 | ||
|
|
83ee962c16 | ||
|
|
f7380f1590 | ||
|
|
121da431ed | ||
|
|
b7c16594ce | ||
|
|
fae16a0184 | ||
|
|
6e9485c2f5 | ||
|
|
ed61a5942c | ||
|
|
5a289c3a15 | ||
|
|
91e63fdb2c | ||
|
|
214066ae5f | ||
|
|
2f34faa50f | ||
|
|
a57dfb828e | ||
|
|
eec6b47ee7 | ||
|
|
f776bf8995 | ||
|
|
69a00888ba | ||
|
|
996044bdc2 | ||
|
|
4674f31dd3 | ||
|
|
fe61337a34 | ||
|
|
b633f7cdf0 | ||
|
|
8df1ac8a86 | ||
|
|
a99a0a6e30 | ||
|
|
773cf387a3 | ||
|
|
7414477bbd | ||
|
|
e8f10ab1ef | ||
|
|
e91ba2ae74 | ||
|
|
95a3e00b03 | ||
|
|
f490600ee2 | ||
|
|
b57219f348 | ||
|
|
ead942abb2 | ||
|
|
d2c32ed14f | ||
|
|
4b9b9ebf47 | ||
|
|
457dfd8c30 | ||
|
|
8ec5cf104c | ||
|
|
306328367e | ||
|
|
3901749d93 | ||
|
|
cee0c3bbd4 | ||
|
|
63ca20db04 | ||
|
|
0388e339db | ||
|
|
d4554b0f4c | ||
|
|
d5471bb36c | ||
|
|
f238eeae0e | ||
|
|
ae9a762439 | ||
|
|
8480927b99 | ||
|
|
24f5761317 | ||
|
|
3d003f9b31 | ||
|
|
1b487da5c2 | ||
|
|
b310018c38 | ||
|
|
fbde37f5a0 | ||
|
|
e6f9987694 | ||
|
|
5a08a1ed3a | ||
|
|
e51abeb5ad | ||
|
|
4c13f849eb | ||
|
|
00bfe2de9f | ||
|
|
95b9c044eb | ||
|
|
911a2cb872 | ||
|
|
0a60695dc9 | ||
|
|
643d7276d7 | ||
|
|
e066c6a8d7 | ||
|
|
b5406c2da0 | ||
|
|
300df18aca | ||
|
|
5efbe048eb | ||
|
|
ead5d96b78 | ||
|
|
e646598078 | ||
|
|
f1ff19e4b8 | ||
|
|
34cf186c33 | ||
|
|
81b7219085 | ||
|
|
3cdc4951e0 | ||
|
|
f839796585 | ||
|
|
e25135934f | ||
|
|
02a8004fd5 | ||
|
|
6b1d2e1943 | ||
|
|
6912e0aeb1 | ||
|
|
c7d1e44719 | ||
|
|
0a6fa0eb35 | ||
|
|
79fdddc075 | ||
|
|
7616f60eca | ||
|
|
6f77f13a04 | ||
|
|
b1f4431767 | ||
|
|
15b6575824 | ||
|
|
5bd785d780 | ||
|
|
94cbcef324 | ||
|
|
d8a6d44890 | ||
|
|
9b75ca4f3f | ||
|
|
913e0071dd | ||
|
|
bb6b8cb8d2 | ||
|
|
efbf998ebb | ||
|
|
ce0f41c973 | ||
|
|
2efaa4fb35 | ||
|
|
2d52d16933 | ||
|
|
bbb9e5c054 | ||
|
|
15c375bca5 | ||
|
|
d2a319a4d5 | ||
|
|
2359b80250 |
No files matched your search
+8
-8
@@ -1,10 +1,3 @@
|
|||||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
|
||||||
[target."cfg(windows)"]
|
|
||||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
|
||||||
|
|
||||||
[target.x86_64-pc-windows-msvc]
|
|
||||||
linker = "rust-lld"
|
|
||||||
|
|
||||||
# Reproducible public-service defaults for downstream desktop builds. Private
|
# Reproducible public-service defaults for downstream desktop builds. Private
|
||||||
# Modrinth services remain disabled by Axolotl capabilities at runtime.
|
# Modrinth services remain disabled by Axolotl capabilities at runtime.
|
||||||
[env]
|
[env]
|
||||||
@@ -12,5 +5,12 @@ MODRINTH_API_BASE_URL = "https://api.modrinth.com"
|
|||||||
MODRINTH_API_URL = "https://api.modrinth.com/v2/"
|
MODRINTH_API_URL = "https://api.modrinth.com/v2/"
|
||||||
MODRINTH_API_URL_V3 = "https://api.modrinth.com/v3/"
|
MODRINTH_API_URL_V3 = "https://api.modrinth.com/v3/"
|
||||||
MODRINTH_LAUNCHER_META_URL = "https://launcher-meta.modrinth.com/"
|
MODRINTH_LAUNCHER_META_URL = "https://launcher-meta.modrinth.com/"
|
||||||
MODRINTH_URL = "https://modrinth.com/"
|
|
||||||
MODRINTH_SOCKET_URL = "wss://disabled.invalid/"
|
MODRINTH_SOCKET_URL = "wss://disabled.invalid/"
|
||||||
|
MODRINTH_URL = "https://modrinth.com/"
|
||||||
|
|
||||||
|
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||||
|
[target."cfg(windows)"]
|
||||||
|
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
linker = "rust-lld"
|
||||||
@@ -351,6 +351,29 @@ jobs:
|
|||||||
release-check/update-server-catalog.json
|
release-check/update-server-catalog.json
|
||||||
node scripts/axolotl/create-update-server-catalog.test.mjs
|
node scripts/axolotl/create-update-server-catalog.test.mjs
|
||||||
|
|
||||||
|
- name: Build complete skin-site update package
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
bundle_root="release-check/skin-site-update"
|
||||||
|
bundle_name="Starlight_Launcher_${version}_skin-site-update.zip"
|
||||||
|
|
||||||
|
mkdir -p "${bundle_root}/files"
|
||||||
|
gh release download "${GITHUB_REF_NAME}" --dir "${bundle_root}/files"
|
||||||
|
find "${bundle_root}/files" -maxdepth 1 -type f \
|
||||||
|
\( -name 'latest.json' -o -name '*_skin-site-update.zip' \) -delete
|
||||||
|
|
||||||
|
cp release-check/latest.json "${bundle_root}/latest.json"
|
||||||
|
cp release-check/update-server-catalog.json "${bundle_root}/update-server-catalog.json"
|
||||||
|
cp release-check/release.json "${bundle_root}/release.json"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "${bundle_root}"
|
||||||
|
zip -r -9 "../${bundle_name}" latest.json update-server-catalog.json release.json files
|
||||||
|
)
|
||||||
|
gh release upload "${GITHUB_REF_NAME}" "release-check/${bundle_name}" --clobber
|
||||||
|
|
||||||
- name: Notify Update Server to publish release assets
|
- name: Notify Update Server to publish release assets
|
||||||
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
|
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ jobs:
|
|||||||
name: Post changelog comment
|
name: Post changelog comment
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Post or update changelog comment
|
- name: Post or update changelog comment
|
||||||
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
|
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
|
||||||
|
|||||||
@@ -22,8 +22,5 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
|
- run: npx --yes tombi@1.6.1 lint
|
||||||
with:
|
- run: npx --yes tombi@1.6.1 fmt --check
|
||||||
tool: tombi@1.1.2
|
|
||||||
- run: tombi lint
|
|
||||||
- run: tombi fmt --check
|
|
||||||
@@ -38,6 +38,7 @@ node_modules
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
testem.log
|
testem.log
|
||||||
|
replay_pid*.log
|
||||||
/typings
|
/typings
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
|
|||||||
Generated
+50
-2
@@ -1889,6 +1889,28 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fontconfig-parser"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||||
|
dependencies = [
|
||||||
|
"roxmltree",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fontdb"
|
||||||
|
version = "0.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
|
||||||
|
dependencies = [
|
||||||
|
"fontconfig-parser",
|
||||||
|
"log",
|
||||||
|
"memmap2",
|
||||||
|
"slotmap",
|
||||||
|
"tinyvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -3477,6 +3499,15 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memmap2"
|
||||||
|
version = "0.9.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memoffset"
|
name = "memoffset"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -5102,6 +5133,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roxmltree"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -5758,6 +5795,15 @@ version = "0.4.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slotmap"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
|
||||||
|
dependencies = [
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.15.2"
|
version = "1.15.2"
|
||||||
@@ -6795,7 +6841,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "theseus"
|
name = "theseus"
|
||||||
version = "1.0.0-beta12"
|
version = "1.0.0-beta15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ariadne",
|
"ariadne",
|
||||||
"async-minecraft-ping",
|
"async-minecraft-ping",
|
||||||
@@ -6899,7 +6945,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "theseus_gui"
|
name = "theseus_gui"
|
||||||
version = "1.0.0-beta12"
|
version = "1.0.0-beta15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ashpd",
|
"ashpd",
|
||||||
"async_zip",
|
"async_zip",
|
||||||
@@ -6912,6 +6958,7 @@ dependencies = [
|
|||||||
"either",
|
"either",
|
||||||
"enumset",
|
"enumset",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"fontdb",
|
||||||
"futures",
|
"futures",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -6926,6 +6973,7 @@ dependencies = [
|
|||||||
"path-util",
|
"path-util",
|
||||||
"quartz_nbt",
|
"quartz_nbt",
|
||||||
"regex",
|
"regex",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
|
|||||||
+8
-24
@@ -20,9 +20,7 @@ ariadne = { path = "packages/ariadne" }
|
|||||||
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
|
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
|
||||||
async-recursion = "1.1.1"
|
async-recursion = "1.1.1"
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
async-tungstenite = { version = "0.31.0", default-features = false, features = [
|
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
|
||||||
"futures-03-sink"
|
|
||||||
] }
|
|
||||||
async-walkdir = "2.1.0"
|
async-walkdir = "2.1.0"
|
||||||
async_zip = "0.0.18"
|
async_zip = "0.0.18"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
@@ -32,9 +30,7 @@ bytes = "1.10.1"
|
|||||||
cc = "1.2.56"
|
cc = "1.2.56"
|
||||||
chardetng = "0.1.17"
|
chardetng = "0.1.17"
|
||||||
chrono = "0.4.42"
|
chrono = "0.4.42"
|
||||||
cidre = { version = "0.15.0", default-features = false, features = [
|
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
|
||||||
"macos_15_0"
|
|
||||||
] }
|
|
||||||
crc32fast = "1.5.0"
|
crc32fast = "1.5.0"
|
||||||
daedalus = { path = "packages/daedalus" }
|
daedalus = { path = "packages/daedalus" }
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
@@ -48,6 +44,7 @@ encoding_rs = "0.8.35"
|
|||||||
enumset = "1.1.10"
|
enumset = "1.1.10"
|
||||||
eyre = "0.6.12"
|
eyre = "0.6.12"
|
||||||
flate2 = "1.1.4"
|
flate2 = "1.1.4"
|
||||||
|
fontdb = "0.24.0"
|
||||||
fs4 = { version = "0.13.1", default-features = false }
|
fs4 = { version = "0.13.1", default-features = false }
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
futures-lite = "2.6.1"
|
futures-lite = "2.6.1"
|
||||||
@@ -58,11 +55,7 @@ hmac = "0.12.1"
|
|||||||
http = "1.3.1"
|
http = "1.3.1"
|
||||||
hyper = "1.7.0"
|
hyper = "1.7.0"
|
||||||
hyper-util = "0.1.17"
|
hyper-util = "0.1.17"
|
||||||
image = { version = "0.25.5", default-features = false, features = [
|
image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
"jpeg",
|
|
||||||
"png",
|
|
||||||
"webp"
|
|
||||||
] }
|
|
||||||
indicatif = "0.18.0"
|
indicatif = "0.18.0"
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
jsonwebtoken = "9.3.1"
|
jsonwebtoken = "9.3.1"
|
||||||
@@ -88,13 +81,10 @@ rand = "=0.8.5" # Locked on 0.8 until p256 updates to 0.9
|
|||||||
regex = "1.12.2"
|
regex = "1.12.2"
|
||||||
reqwest = { version = "0.12.24", default-features = false }
|
reqwest = { version = "0.12.24", default-features = false }
|
||||||
rgb = "0.8.52"
|
rgb = "0.8.52"
|
||||||
rustls = { version = "0.23.42", default-features = false, features = [
|
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
|
||||||
"ring",
|
|
||||||
"std",
|
|
||||||
"tls12"
|
|
||||||
] }
|
|
||||||
rustls-native-certs = "0.8.4"
|
rustls-native-certs = "0.8.4"
|
||||||
rustls-pki-types = "1.15.0"
|
rustls-pki-types = "1.15.0"
|
||||||
|
semver = "1.0.28"
|
||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
serde_bytes = "0.11.19"
|
serde_bytes = "0.11.19"
|
||||||
serde_cbor = "0.11.2"
|
serde_cbor = "0.11.2"
|
||||||
@@ -127,10 +117,7 @@ tempfile = "3.23.0"
|
|||||||
theseus = { path = "packages/app-lib" }
|
theseus = { path = "packages/app-lib" }
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = "1.47.1"
|
tokio = "1.47.1"
|
||||||
tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
|
||||||
"ring",
|
|
||||||
"tls12"
|
|
||||||
] }
|
|
||||||
tokio-util = "0.7.16"
|
tokio-util = "0.7.16"
|
||||||
toml = "0.8.19"
|
toml = "0.8.19"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
@@ -149,10 +136,7 @@ windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
|
|||||||
winreg = "0.55.0"
|
winreg = "0.55.0"
|
||||||
xz2 = { version = "0.1.7", features = ["static"] }
|
xz2 = { version = "0.1.7", features = ["static"] }
|
||||||
zbus = "5.11.0"
|
zbus = "5.11.0"
|
||||||
zhconv = { version = "0.4.1", default-features = false, features = [
|
zhconv = { version = "0.4.1", default-features = false, features = ["compress", "opencc"] }
|
||||||
"compress",
|
|
||||||
"opencc"
|
|
||||||
] }
|
|
||||||
zip = { version = "6.0.0", default-features = false, features = [
|
zip = { version = "6.0.0", default-features = false, features = [
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"deflate",
|
"deflate",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ extend-exclude = [
|
|||||||
"packages/app-lib/src/api/content_search/WikiEntries.txt",
|
"packages/app-lib/src/api/content_search/WikiEntries.txt",
|
||||||
# Bundled Monaco editor runtime contains upstream generated assets.
|
# Bundled Monaco editor runtime contains upstream generated assets.
|
||||||
"apps/app-frontend/public/monaco/",
|
"apps/app-frontend/public/monaco/",
|
||||||
|
# Minecraft language-name data (from mcmeta); contains language codes that look like typos.
|
||||||
|
"apps/app-frontend/src/components/ui/settings/instances/game-settings-modal/languages.ts",
|
||||||
]
|
]
|
||||||
|
|
||||||
[default.extend-words]
|
[default.extend-words]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@modrinth/app-frontend",
|
"name": "@modrinth/app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta12",
|
"version": "1.0.0-beta15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
|
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "6.3.1",
|
||||||
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
|
"@dnd-kit/vue": "^0.5.0",
|
||||||
"@lobehub/icons-static-svg": "1.94.0",
|
"@lobehub/icons-static-svg": "1.94.0",
|
||||||
"@modrinth/api-client": "workspace:^",
|
"@modrinth/api-client": "workspace:^",
|
||||||
"@modrinth/assets": "workspace:*",
|
"@modrinth/assets": "workspace:*",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
FlaskConicalIcon,
|
FlaskConicalIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
|
ImagesIcon,
|
||||||
LeftArrowIcon,
|
LeftArrowIcon,
|
||||||
LibraryIcon,
|
LibraryIcon,
|
||||||
LogInIcon,
|
LogInIcon,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
WorldIcon,
|
WorldIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +68,8 @@ import { hideAllPoppers } from 'floating-vue'
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||||
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
|
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
|
||||||
|
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
|
||||||
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
|
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
|
||||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||||
import AppActionBar from '@/components/ui/AppActionBar.vue'
|
import AppActionBar from '@/components/ui/AppActionBar.vue'
|
||||||
@@ -79,7 +83,6 @@ import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/
|
|||||||
import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue'
|
import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue'
|
||||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||||
import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue'
|
import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue'
|
||||||
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
|
|
||||||
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||||
import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue'
|
import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue'
|
||||||
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
|
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
|
||||||
@@ -90,9 +93,9 @@ import NavButton from '@/components/ui/NavButton.vue'
|
|||||||
import NavRail from '@/components/ui/NavRail.vue'
|
import NavRail from '@/components/ui/NavRail.vue'
|
||||||
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
|
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
|
||||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
|
||||||
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
|
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
|
||||||
import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
|
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
|
||||||
|
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||||
import { useDropImport } from '@/composables/useDropImport'
|
import { useDropImport } from '@/composables/useDropImport'
|
||||||
@@ -138,9 +141,9 @@ import {
|
|||||||
isDev,
|
isDev,
|
||||||
isElevated,
|
isElevated,
|
||||||
isNetworkMetered,
|
isNetworkMetered,
|
||||||
restartApp,
|
|
||||||
setRestartAfterPendingUpdate,
|
setRestartAfterPendingUpdate,
|
||||||
} from '@/helpers/utils.js'
|
} from '@/helpers/utils.js'
|
||||||
|
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
|
||||||
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
|
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
|
||||||
import i18n, { resolveInitialLocale } from '@/i18n.config'
|
import i18n, { resolveInitialLocale } from '@/i18n.config'
|
||||||
import {
|
import {
|
||||||
@@ -312,6 +315,22 @@ const showOnboarding = ref(false)
|
|||||||
const onboardingMode = ref('main')
|
const onboardingMode = ref('main')
|
||||||
const onboardingSettings = ref(null)
|
const onboardingSettings = ref(null)
|
||||||
const onboardingReplay = ref(false)
|
const onboardingReplay = ref(false)
|
||||||
|
|
||||||
|
// Expand the sidebar while the onboarding tour is visible so steps that point
|
||||||
|
// at sidebar entries (account card, announcement, create instance) can find
|
||||||
|
// their targets. The previous state is restored when the tour ends.
|
||||||
|
let sidebarStateBeforeOnboarding: boolean | undefined
|
||||||
|
watch(showOnboarding, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
sidebarStateBeforeOnboarding = sidebarToggled.value
|
||||||
|
sidebarToggled.value = true
|
||||||
|
setSidebarExpanded(true)
|
||||||
|
} else if (sidebarStateBeforeOnboarding !== undefined) {
|
||||||
|
sidebarToggled.value = sidebarStateBeforeOnboarding
|
||||||
|
setSidebarExpanded(sidebarStateBeforeOnboarding)
|
||||||
|
sidebarStateBeforeOnboarding = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
const nativeDecorations = ref(false)
|
const nativeDecorations = ref(false)
|
||||||
|
|
||||||
const os = ref('')
|
const os = ref('')
|
||||||
@@ -490,9 +509,16 @@ onMounted(async () => {
|
|||||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||||
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, handleDirectLinkSyncReport)
|
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, handleDirectLinkSyncReport)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stateInitialization
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
checkUpdates()
|
checkUpdates()
|
||||||
void warnIfRunningElevated()
|
void warnIfRunningElevated()
|
||||||
startDirectLinkSync()
|
startDirectLinkSync()
|
||||||
|
maybeShowStartupAnnouncement()
|
||||||
})
|
})
|
||||||
|
|
||||||
let directLinkSync: (() => Promise<void>) | undefined
|
let directLinkSync: (() => Promise<void>) | undefined
|
||||||
@@ -741,6 +767,10 @@ const messages = defineMessages({
|
|||||||
id: 'app.navigation.multiplayer',
|
id: 'app.navigation.multiplayer',
|
||||||
defaultMessage: 'Multiplayer',
|
defaultMessage: 'Multiplayer',
|
||||||
},
|
},
|
||||||
|
screenshots: {
|
||||||
|
id: 'app.navigation.screenshots',
|
||||||
|
defaultMessage: 'Screenshots',
|
||||||
|
},
|
||||||
downloads: {
|
downloads: {
|
||||||
id: 'app.navigation.downloads',
|
id: 'app.navigation.downloads',
|
||||||
defaultMessage: 'Downloads',
|
defaultMessage: 'Downloads',
|
||||||
@@ -1066,6 +1096,7 @@ async function setupApp() {
|
|||||||
close_behavior,
|
close_behavior,
|
||||||
developer_mode,
|
developer_mode,
|
||||||
feature_flags,
|
feature_flags,
|
||||||
|
ui_scale,
|
||||||
} = initialSettings
|
} = initialSettings
|
||||||
|
|
||||||
// Initialize locale from saved settings
|
// Initialize locale from saved settings
|
||||||
@@ -1096,6 +1127,7 @@ async function setupApp() {
|
|||||||
nativeDecorations.value = native_decorations
|
nativeDecorations.value = native_decorations
|
||||||
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
|
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
|
||||||
|
|
||||||
|
setUiScale(ui_scale)
|
||||||
themeStore.setThemeState(theme)
|
themeStore.setThemeState(theme)
|
||||||
await initializeSystemAccentColor()
|
await initializeSystemAccentColor()
|
||||||
themeStore.setAccentColor(accent_color)
|
themeStore.setAccentColor(accent_color)
|
||||||
@@ -1228,7 +1260,7 @@ async function replayOnboarding(mode) {
|
|||||||
|
|
||||||
async function finishOnboarding() {
|
async function finishOnboarding() {
|
||||||
const wasReplay = onboardingReplay.value
|
const wasReplay = onboardingReplay.value
|
||||||
const settings = onboardingSettings.value ?? (await getSettings())
|
const settings = await getSettings()
|
||||||
if (!onboardingReplay.value) {
|
if (!onboardingReplay.value) {
|
||||||
if (onboardingMode.value === 'instance') {
|
if (onboardingMode.value === 'instance') {
|
||||||
settings.onboarding_instance_tour_completed = true
|
settings.onboarding_instance_tour_completed = true
|
||||||
@@ -1264,6 +1296,9 @@ provide(
|
|||||||
)
|
)
|
||||||
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
|
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
|
||||||
provide('showLauncherPopup', (_request: unknown) => {})
|
provide('showLauncherPopup', (_request: unknown) => {})
|
||||||
|
provide('showStartupAnnouncement', () => startupAnnouncementModal.value?.show())
|
||||||
|
|
||||||
|
installUiScale()
|
||||||
|
|
||||||
const stateFailed = ref(false)
|
const stateFailed = ref(false)
|
||||||
stateInitialization
|
stateInitialization
|
||||||
@@ -1533,6 +1568,33 @@ const updateToPlayModal = ref()
|
|||||||
|
|
||||||
const modrinthLoginFlowWaitModal = ref()
|
const modrinthLoginFlowWaitModal = ref()
|
||||||
|
|
||||||
|
const startupAnnouncementModal = ref<InstanceType<typeof StartupAnnouncementModal> | null>(null)
|
||||||
|
const STARTUP_ANNOUNCEMENT_KEY = 'starlight-startup-announcement-seen-v1'
|
||||||
|
|
||||||
|
function maybeShowStartupAnnouncement() {
|
||||||
|
let alreadySeen = false
|
||||||
|
try {
|
||||||
|
alreadySeen = localStorage.getItem(STARTUP_ANNOUNCEMENT_KEY) !== null
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read startup announcement flag', error)
|
||||||
|
}
|
||||||
|
if (alreadySeen) return
|
||||||
|
|
||||||
|
const modal = startupAnnouncementModal.value
|
||||||
|
if (!modal) {
|
||||||
|
// The modal component has not mounted yet; try again on the next tick.
|
||||||
|
void nextTick(maybeShowStartupAnnouncement)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.show()
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STARTUP_ANNOUNCEMENT_KEY, '1')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to write startup announcement flag', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Drop import system ──────────────────────────────────────────────────
|
// ── Drop import system ──────────────────────────────────────────────────
|
||||||
const dropImport = useDropImport({
|
const dropImport = useDropImport({
|
||||||
notificationManager,
|
notificationManager,
|
||||||
@@ -1701,6 +1763,7 @@ onMounted(() => {
|
|||||||
setServerUpdateToPlayModal(updateToPlayModal.value)
|
setServerUpdateToPlayModal(updateToPlayModal.value)
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
await stateInitialization
|
||||||
const ready = await invoke<{
|
const ready = await invoke<{
|
||||||
pending_crashes: { instance_id: string; uuid: string }[]
|
pending_crashes: { instance_id: string; uuid: string }[]
|
||||||
pending_commands: Parameters<typeof handleCommand>[0][]
|
pending_commands: Parameters<typeof handleCommand>[0][]
|
||||||
@@ -2161,6 +2224,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||||
<SkinSiteSessionFrame v-if="stateInitialized" />
|
<SkinSiteSessionFrame v-if="stateInitialized" />
|
||||||
<InstancePlayerModal v-if="stateInitialized" />
|
<InstancePlayerModal v-if="stateInitialized" />
|
||||||
|
<StartupAnnouncementModal ref="startupAnnouncementModal" />
|
||||||
<div id="teleports"></div>
|
<div id="teleports"></div>
|
||||||
<div
|
<div
|
||||||
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
||||||
@@ -2256,6 +2320,23 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
>
|
>
|
||||||
<LibraryIcon />
|
<LibraryIcon />
|
||||||
</NavButton>
|
</NavButton>
|
||||||
|
<NavButton
|
||||||
|
v-if="themeStore.featureFlags.multiplayer_in_app"
|
||||||
|
v-tooltip.right="formatMessage(messages.multiplayer)"
|
||||||
|
data-onboarding-id="nav-multiplayer"
|
||||||
|
to="/multiplayer"
|
||||||
|
:is-primary="(r) => r.path.startsWith('/multiplayer')"
|
||||||
|
>
|
||||||
|
<UsersIcon />
|
||||||
|
</NavButton>
|
||||||
|
<NavButton
|
||||||
|
v-tooltip.right="formatMessage(messages.screenshots)"
|
||||||
|
data-onboarding-id="nav-screenshots"
|
||||||
|
to="/screenshots"
|
||||||
|
:is-primary="(r) => r.path.startsWith('/screenshots')"
|
||||||
|
>
|
||||||
|
<ImagesIcon />
|
||||||
|
</NavButton>
|
||||||
<NavButton
|
<NavButton
|
||||||
v-tooltip.right="formatMessage(messages.lab)"
|
v-tooltip.right="formatMessage(messages.lab)"
|
||||||
data-onboarding-id="nav-lab"
|
data-onboarding-id="nav-lab"
|
||||||
@@ -2423,9 +2504,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
{{ formatMessage(messages.authUnreachableBody) }}
|
{{ formatMessage(messages.authUnreachableBody) }}
|
||||||
</Admonition>
|
</Admonition>
|
||||||
<div class="page-transition-grid grid min-h-full">
|
<div class="page-transition-grid grid min-h-full">
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView v-slot="{ Component, route: currentRoute }">
|
||||||
<Transition name="page-slide" :css="themeStore.getFeatureFlag('page_transitions')">
|
<Transition name="page-slide" :css="themeStore.getFeatureFlag('page_transitions')">
|
||||||
<div v-if="Component" :key="getPageTransitionKey(route)" class="page-transition-layer">
|
<div
|
||||||
|
v-if="Component"
|
||||||
|
:key="getPageTransitionKey(currentRoute)"
|
||||||
|
class="page-transition-layer"
|
||||||
|
>
|
||||||
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
|
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
|
||||||
<component :is="Component"></component>
|
<component :is="Component"></component>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -2465,7 +2550,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
>
|
>
|
||||||
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
|
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
|
||||||
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
<div
|
||||||
|
data-onboarding-id="sidebar-accounts"
|
||||||
|
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||||
|
>
|
||||||
<h3 class="text-base text-primary font-medium m-0">
|
<h3 class="text-base text-primary font-medium m-0">
|
||||||
{{ formatMessage(messages.userInformation) }}
|
{{ formatMessage(messages.userInformation) }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -2489,7 +2577,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
:on-error-action="exportNotificationErrorLogs"
|
:on-error-action="exportNotificationErrorLogs"
|
||||||
:error-action-label="formatMessage(messages.exportErrorLogs)"
|
:error-action-label="formatMessage(messages.exportErrorLogs)"
|
||||||
/>
|
/>
|
||||||
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
|
<MinecraftCrashModal v-if="stateInitialized" ref="minecraftCrashModal" @error="handleError" />
|
||||||
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
|
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
|
||||||
<NewModal
|
<NewModal
|
||||||
ref="closeChoiceModal"
|
ref="closeChoiceModal"
|
||||||
@@ -2577,7 +2665,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
@install="handleContentInstallModpackInstall"
|
@install="handleContentInstallModpackInstall"
|
||||||
@cancel="handleContentInstallModpackInstallCancel"
|
@cancel="handleContentInstallModpackInstallCancel"
|
||||||
/>
|
/>
|
||||||
<TaggedModDownloadsModal />
|
<TaggedModDownloadsModal v-if="stateInitialized" />
|
||||||
<CurseForgeManualDownloadsModal
|
<CurseForgeManualDownloadsModal
|
||||||
ref="contentInstallCurseForgeManualDownloadsModal"
|
ref="contentInstallCurseForgeManualDownloadsModal"
|
||||||
@view-instance="handleContentInstallModpackDuplicateGoToInstance"
|
@view-instance="handleContentInstallModpackDuplicateGoToInstance"
|
||||||
|
|||||||
@@ -32,6 +32,357 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1',
|
||||||
|
version: '1.0.1',
|
||||||
|
publishedAt: '2026-10-03',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US': 'Instance backups: create deduplicated snapshots of an instance while it is closed, preview and restore them, exclude specific files or folders, and move the backup repository.',
|
||||||
|
'zh-CN': '实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': "Customize the launcher's UI font and monospace font in appearance settings.",
|
||||||
|
'zh-CN': '在外观设置中自定义启动器界面字体与等宽字体。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed beta builds not auto-updating from beta9 to beta10 (prerelease versions were compared as text instead of by number).',
|
||||||
|
'zh-CN': '修复 beta 版本无法从 beta9 自动更新到 beta10 的问题(预发布版本此前按文本而非数字比较)。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed a cross-crate visibility issue that prevented the launcher from compiling with the ignore-SSL-errors option.',
|
||||||
|
'zh-CN': '修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta10',
|
||||||
|
version: '1.0.1-beta10',
|
||||||
|
publishedAt: '2026-10-02',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta10',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta10',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US': 'Reworked the instance-page tour into four steps covering content, files, worlds, and logs.',
|
||||||
|
'zh-CN': '重做实例页导览,分为内容、文件、世界、日志四步。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'When the install location is a drive root, the installer now creates a Starlight subfolder automatically.',
|
||||||
|
'zh-CN': '安装位置选到盘符根目录时,安装器会自动创建 Starlight 子文件夹。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed the launcher UI scale reverting after a restart.',
|
||||||
|
'zh-CN': '修复启动器界面缩放重启后恢复原状的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed developer feature flags not saving.',
|
||||||
|
'zh-CN': '修复开发者功能开关无法保存的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed instance group edits not saving for instances without an applied content set.',
|
||||||
|
'zh-CN': '修复没有应用内容集的实例无法保存分组修改的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.',
|
||||||
|
'zh-CN': '修复前后端功能开关不同步导致启动器启动崩溃的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed being unable to copy text inside the embedded skin site.',
|
||||||
|
'zh-CN': '修复内嵌皮肤站中无法复制文本的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed the onboarding prompt not dismissing when clicking anywhere.',
|
||||||
|
'zh-CN': '修复导览提示点击任意位置无法关闭的问题。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta9',
|
||||||
|
version: '1.0.1-beta9',
|
||||||
|
publishedAt: '2026-10-02',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta9',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta9',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed a crash on the minimal home layout caused by an unrecognized multiplayer feature flag (missing backend enum variant).',
|
||||||
|
'zh-CN': '修复多人联机功能开关缺少后端枚举变体、导致精简主页报错的问题。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta8',
|
||||||
|
version: '1.0.1-beta8',
|
||||||
|
publishedAt: '2026-10-02',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta8',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta8',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed the sidebar navigation entries breaking after the screenshot center was added.',
|
||||||
|
'zh-CN': '修复添加截图中心后侧边栏导航项损坏的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed being unable to install the StarLight hosted pack even after signing in (missing command permission).',
|
||||||
|
'zh-CN': '修复登录后仍无法安装 StarLight 实例的问题(命令权限缺失)。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed the launcher UI scaling not applying when changed (missing command permission).',
|
||||||
|
'zh-CN': '修复修改启动器界面缩放后不生效的问题(命令权限缺失)。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed several screenshot center actions failing due to missing command permissions.',
|
||||||
|
'zh-CN': '修复截图中心部分操作因命令权限缺失而失败的问题。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta7',
|
||||||
|
version: '1.0.1-beta7',
|
||||||
|
publishedAt: '2026-10-02',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta7',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta7',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Added a screenshot center: browse screenshots across every instance, group them (custom groups, by instance, or by date), preview, edit (crop, rotate, annotate, censor), and export as ZIP.',
|
||||||
|
'zh-CN':
|
||||||
|
'新增截图中心:跨实例浏览截图,支持自定义/按实例/按日期分组,可预览、编辑(裁剪、标注、打码等)并导出为 ZIP。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Added the multiplayer (Terracotta) entry to the sidebar, behind a feature flag.',
|
||||||
|
'zh-CN': '侧边栏新增多人联机(陶瓦)入口,由功能开关控制。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Launcher UI scaling is now persisted in the backend and applied via native webview zoom, so it scales embedded pages correctly and keeps onboarding anchors aligned.',
|
||||||
|
'zh-CN':
|
||||||
|
'启动器界面缩放改为后端持久化,并通过原生 webview 缩放应用,可正确缩放内嵌页面且导览锚点不再偏移。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed the onboarding tour skipping its first step when the target anchor was missing.',
|
||||||
|
'zh-CN': '修复导览首步在目标锚点缺失时被自动跳过的问题。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta6',
|
||||||
|
version: '1.0.1-beta6',
|
||||||
|
publishedAt: '2026-10-01',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta6',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta6',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.',
|
||||||
|
'zh-CN':
|
||||||
|
'修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed the onboarding tour target on the minimal home layout.',
|
||||||
|
'zh-CN': '修复精简主页下导览目标找不到的问题。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US': 'Feature flag names in developer settings are now shown in Chinese.',
|
||||||
|
'zh-CN': '开发者设置中的功能开关名现在显示中文。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta5',
|
||||||
|
version: '1.0.1-beta5',
|
||||||
|
publishedAt: '2026-10-01',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta5',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta5',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Reworked the onboarding tour, covering the home page, StarLight login, announcements, instance creation, discovery, skins, library, lab, downloads, and update settings in order.',
|
||||||
|
'zh-CN':
|
||||||
|
'重做了新手导览,依次介绍主页、StarLight 登录、公告、创建实例、发现内容、皮肤、实例库、实验室、下载与更新设置。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed the developer feature flags page rendering blank.',
|
||||||
|
'zh-CN': '修复开发者“功能开关”页面空白的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed a build error that prevented the launcher from compiling.',
|
||||||
|
'zh-CN': '修复导致启动器无法编译的构建错误。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta4',
|
||||||
|
version: '1.0.1-beta4',
|
||||||
|
publishedAt: '2026-09-30',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta4',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta4',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
|
||||||
|
'zh-CN': '修复 1.0.1-beta3 中导致启动器无法编译的构建错误。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta3',
|
||||||
|
version: '1.0.1-beta3',
|
||||||
|
publishedAt: '2026-09-30',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta3',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta3',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Added a launcher UI scale setting under Appearance, which also helps when embedded pages are cut off.',
|
||||||
|
'zh-CN': '在“界面与外观”中新增启动器缩放设置,同时可缓解内嵌网页被裁切的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Added a one-time welcome announcement on first launch, with an entry in the home sidebar.',
|
||||||
|
'zh-CN': '新增首次启动时的欢迎公告弹窗,并在主页侧栏提供入口。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed the browse page going blank without a CurseForge API key; the navigation tabs now stay visible.',
|
||||||
|
'zh-CN': '修复未配置 CurseForge API Key 时浏览页整页空白的问题,导航栏现在保持可见。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed the about page license links and the update changelog link to point at the correct repository.',
|
||||||
|
'zh-CN': '修复关于页许可证链接与更新日志链接,改为指向正确的仓库。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta2',
|
||||||
|
version: '1.0.1-beta2',
|
||||||
|
publishedAt: '2026-09-28',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta2',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta2',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'GitHub releases now include one complete update package for publishing to the StarLight skin site.',
|
||||||
|
'zh-CN': 'GitHub 发布现会生成一个可直接发布到 StarLight 皮肤站的完整更新包。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Improved the reliability of live logs and database switching.',
|
||||||
|
'zh-CN': '提高实时日志与数据库切换的可靠性。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.1-beta1',
|
||||||
|
version: '1.0.1-beta1',
|
||||||
|
publishedAt: '2026-09-27',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.1-beta1',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.1-beta1',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Added a one-time welcome announcement on first launch, covering the development team, the StarLight-only note, the rulebook update, and server connection addresses.',
|
||||||
|
'zh-CN':
|
||||||
|
'新增首次启动时的欢迎公告弹窗,包含开发团队、StarLight 专用说明、服规更新与服务器连接地址。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.0',
|
||||||
|
version: '1.0.0',
|
||||||
|
publishedAt: '2026-09-26',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.0',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.0',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Enabled the built-in automatic updater. The launcher can now check for and install new versions from the update channel.',
|
||||||
|
'zh-CN': '启用内置自动更新,启动器现在可以从更新通道检查并安装新版本。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US':
|
||||||
|
'Fixed a crash on startup caused by the updater running before launcher state finished initializing.',
|
||||||
|
'zh-CN': '修复了更新器在启动器状态初始化完成前运行导致的启动崩溃。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'launcher-1.0.0-beta15',
|
id: 'launcher-1.0.0-beta15',
|
||||||
version: '1.0.0-beta15',
|
version: '1.0.0-beta15',
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NewspaperIcon, RightArrowIcon } from '@modrinth/assets'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const showAnnouncement = inject<(() => void) | undefined>('showStartupAnnouncement')
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
showAnnouncement?.()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
data-onboarding-id="home-announcement"
|
||||||
|
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex w-full cursor-pointer flex-col gap-2 border-0 bg-transparent p-0 text-left"
|
||||||
|
@click="open"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<NewspaperIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||||
|
<h2 class="m-0 truncate text-lg">公告</h2>
|
||||||
|
<RightArrowIcon
|
||||||
|
class="ml-auto size-4 shrink-0 text-secondary transition-transform group-hover:translate-x-0.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="m-0 text-sm leading-relaxed text-secondary">点击查看最新公告</p>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -28,8 +28,8 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
|||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
import { get_by_instance_id } from '@/helpers/process'
|
|
||||||
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
||||||
|
import { get_by_instance_id } from '@/helpers/process'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import { handleSevereError } from '@/store/error'
|
import { handleSevereError } from '@/store/error'
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ onUnmounted(() => {
|
|||||||
data-onboarding-id="home-instances"
|
data-onboarding-id="home-instances"
|
||||||
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8"
|
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8"
|
||||||
>
|
>
|
||||||
<div class="flex w-full max-w-3xl flex-col items-center text-center">
|
<div data-onboarding-id="home-widget-grid" class="flex w-full max-w-3xl flex-col items-center text-center">
|
||||||
<HomeGreeting :player-name="playerName" variant="minimal" />
|
<HomeGreeting :player-name="playerName" variant="minimal" />
|
||||||
|
|
||||||
<template v-if="selectedInstance">
|
<template v-if="selectedInstance">
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const props = withDefaults(
|
|||||||
limit?: HomeRecentLimit
|
limit?: HomeRecentLimit
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
|
dashboardSize: null,
|
||||||
limit: HOME_RECENT_DEFAULT_LIMIT,
|
limit: HOME_RECENT_DEFAULT_LIMIT,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -146,11 +147,17 @@ async function stopInstance(instance: GameInstance) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
|
<section
|
||||||
|
class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3"
|
||||||
|
:data-size="dashboardSize"
|
||||||
|
>
|
||||||
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
|
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
|
||||||
<h2>{{ formatMessage(messages.recentTitle) }}</h2>
|
<h2>{{ formatMessage(messages.recentTitle) }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="recentItems.length > 0" class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1">
|
<div
|
||||||
|
v-if="recentItems.length > 0"
|
||||||
|
class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
<template
|
<template
|
||||||
v-for="item in recentItems"
|
v-for="item in recentItems"
|
||||||
:key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`"
|
:key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ProgressBar, defineMessages, useVIntl } from '@modrinth/ui'
|
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { injectDownloadManager } from '@/providers/download-manager'
|
import { injectDownloadManager } from '@/providers/download-manager'
|
||||||
|
|
||||||
const props = defineProps<{ instanceId?: string; active?: boolean }>()
|
const props = defineProps<{ instanceId?: string; active?: boolean }>()
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ import {
|
|||||||
import { users } from '@/helpers/auth'
|
import { users } from '@/helpers/auth'
|
||||||
import { getInstanceMode } from '@/helpers/hosted-packs'
|
import { getInstanceMode } from '@/helpers/hosted-packs'
|
||||||
import {
|
import {
|
||||||
|
type InstancePlayer,
|
||||||
|
type PlayerChoice,
|
||||||
PlayerSelectionNavigatedAwayError,
|
PlayerSelectionNavigatedAwayError,
|
||||||
registerInstancePlayerPicker,
|
registerInstancePlayerPicker,
|
||||||
saveInstancePlayer,
|
saveInstancePlayer,
|
||||||
waitForSkinSiteSession,
|
waitForSkinSiteSession,
|
||||||
type InstancePlayer,
|
|
||||||
type PlayerChoice,
|
|
||||||
} from '@/helpers/instance-player'
|
} from '@/helpers/instance-player'
|
||||||
|
|
||||||
import { playerMessages as messages } from './instance-player-messages'
|
import { playerMessages as messages } from './instance-player-messages'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ButtonStyled, useVIntl } from '@modrinth/ui'
|
import { ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||||
import { onUnmounted, ref, watch } from 'vue'
|
import { onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
chooseInstancePlayer,
|
chooseInstancePlayer,
|
||||||
getInstancePlayer,
|
getInstancePlayer,
|
||||||
onInstancePlayerChanged,
|
|
||||||
type InstancePlayer,
|
type InstancePlayer,
|
||||||
|
onInstancePlayerChanged,
|
||||||
} from '@/helpers/instance-player'
|
} from '@/helpers/instance-player'
|
||||||
|
|
||||||
import { playerMessages as messages } from './instance-player-messages'
|
import { playerMessages as messages } from './instance-player-messages'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NewModal, ProgressBar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
import { ButtonStyled, defineMessages, NewModal, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||||
import { onMounted, onUnmounted, nextTick, ref, shallowRef } from 'vue'
|
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { loading_listener } from '@/helpers/events'
|
import { loading_listener } from '@/helpers/events'
|
||||||
import { progress_bars_list } from '@/helpers/state'
|
|
||||||
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
|
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
|
||||||
|
import { progress_bars_list } from '@/helpers/state'
|
||||||
import {
|
import {
|
||||||
createTaggedModProgress,
|
createTaggedModProgress,
|
||||||
type TaggedProgressEvent,
|
type TaggedProgressEvent,
|
||||||
|
|||||||
@@ -12,10 +12,14 @@
|
|||||||
>
|
>
|
||||||
{{ formatMessage(messages.score, { score }) }}
|
{{ formatMessage(messages.score, { score }) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary">
|
<span
|
||||||
|
class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary"
|
||||||
|
>
|
||||||
{{ formatMessage(messages.highestLevel, { level: highestLevel + 1 }) }}
|
{{ formatMessage(messages.highestLevel, { level: highestLevel + 1 }) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary">
|
<span
|
||||||
|
class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary"
|
||||||
|
>
|
||||||
{{ formatMessage(messages.best, { score: bestScore }) }}
|
{{ formatMessage(messages.best, { score: bestScore }) }}
|
||||||
</span>
|
</span>
|
||||||
<NewButton type="base" size="sm" class="shrink-0" @click="settleGame">
|
<NewButton type="base" size="sm" class="shrink-0" @click="settleGame">
|
||||||
@@ -80,7 +84,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<div v-if="gameOver && !endedManually" class="tide-drain pointer-events-none absolute inset-0 z-20" />
|
<div
|
||||||
|
v-if="gameOver && !endedManually"
|
||||||
|
class="tide-drain pointer-events-none absolute inset-0 z-20"
|
||||||
|
/>
|
||||||
<Transition name="panel">
|
<Transition name="panel">
|
||||||
<div
|
<div
|
||||||
v-if="gameOver"
|
v-if="gameOver"
|
||||||
@@ -115,16 +122,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid w-full min-w-0 grid-cols-3 gap-1.5">
|
<div class="grid w-full min-w-0 grid-cols-3 gap-1.5">
|
||||||
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
|
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
|
||||||
<span class="text-[9px] text-secondary">{{ formatMessage(messages.levelLabel) }}</span>
|
<span class="text-[9px] text-secondary">{{
|
||||||
<span class="text-sm font-bold tabular-nums text-contrast">{{ highestLevel + 1 }}</span>
|
formatMessage(messages.levelLabel)
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm font-bold tabular-nums text-contrast">{{
|
||||||
|
highestLevel + 1
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
|
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
|
||||||
<span class="text-[9px] text-secondary">{{ formatMessage(messages.bestLabel) }}</span>
|
<span class="text-[9px] text-secondary">{{
|
||||||
|
formatMessage(messages.bestLabel)
|
||||||
|
}}</span>
|
||||||
<span class="text-sm font-bold tabular-nums text-contrast">{{ bestScore }}</span>
|
<span class="text-sm font-bold tabular-nums text-contrast">{{ bestScore }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
|
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
|
||||||
<span class="text-[9px] text-secondary">{{ formatMessage(messages.overtimesLabel) }}</span>
|
<span class="text-[9px] text-secondary">{{
|
||||||
<span class="text-sm font-bold tabular-nums text-contrast">{{ overtimeCount }}</span>
|
formatMessage(messages.overtimesLabel)
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm font-bold tabular-nums text-contrast">{{
|
||||||
|
overtimeCount
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap justify-center gap-2">
|
<div class="flex flex-wrap justify-center gap-2">
|
||||||
@@ -150,8 +167,8 @@ import blueStar from '@/assets/stars/blue.png'
|
|||||||
import greenStar from '@/assets/stars/green.png'
|
import greenStar from '@/assets/stars/green.png'
|
||||||
import pinkStar from '@/assets/stars/pink.png'
|
import pinkStar from '@/assets/stars/pink.png'
|
||||||
import redStar from '@/assets/stars/red.png'
|
import redStar from '@/assets/stars/red.png'
|
||||||
import yellowStar from '@/assets/stars/yellow.png'
|
|
||||||
import superStar from '@/assets/stars/super.png'
|
import superStar from '@/assets/stars/super.png'
|
||||||
|
import yellowStar from '@/assets/stars/yellow.png'
|
||||||
|
|
||||||
interface Piece {
|
interface Piece {
|
||||||
id: number
|
id: number
|
||||||
@@ -396,7 +413,14 @@ function mergePieces(a: Piece, b: Piece) {
|
|||||||
score.value += points[level]
|
score.value += points[level]
|
||||||
highestLevel.value = Math.max(highestLevel.value, level)
|
highestLevel.value = Math.max(highestLevel.value, level)
|
||||||
const isRainbow = level === colors.length - 1
|
const isRainbow = level === colors.length - 1
|
||||||
spawnBurst(x, y, isRainbow ? rainbowColors : [colors[level]], 10 + level * 3, 130 + level * 20, 3.4)
|
spawnBurst(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
isRainbow ? rainbowColors : [colors[level]],
|
||||||
|
10 + level * 3,
|
||||||
|
130 + level * 20,
|
||||||
|
3.4,
|
||||||
|
)
|
||||||
spawnShockwave(x, y, 46 + level * 26, 'rgba(255, 255, 255, 0.9)', Math.min(3, 1.4 + level * 0.3))
|
spawnShockwave(x, y, 46 + level * 26, 'rgba(255, 255, 255, 0.9)', Math.min(3, 1.4 + level * 0.3))
|
||||||
shakePower = Math.max(shakePower, Math.min(2 + level * 1.2, 7))
|
shakePower = Math.max(shakePower, Math.min(2 + level * 1.2, 7))
|
||||||
spawnFloater(x, y, `+${points[level]}`, 15 + level * 3, colors[level])
|
spawnFloater(x, y, `+${points[level]}`, 15 + level * 3, colors[level])
|
||||||
@@ -423,7 +447,14 @@ function easeOutBack(t: number) {
|
|||||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
|
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnBurst(x: number, y: number, palette: string[], count: number, speed: number, size: number) {
|
function spawnBurst(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
palette: string[],
|
||||||
|
count: number,
|
||||||
|
speed: number,
|
||||||
|
size: number,
|
||||||
|
) {
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
if (particles.length >= 260) return
|
if (particles.length >= 260) return
|
||||||
const angle = Math.random() * Math.PI * 2
|
const angle = Math.random() * Math.PI * 2
|
||||||
@@ -508,7 +539,8 @@ function update(delta: number) {
|
|||||||
if (overtimeStartAt >= 0) {
|
if (overtimeStartAt >= 0) {
|
||||||
const overtimeElapsed = elapsed - overtimeStartAt
|
const overtimeElapsed = elapsed - overtimeStartAt
|
||||||
if (overtimeElapsed < 0.8) {
|
if (overtimeElapsed < 0.8) {
|
||||||
surfaceRatio = overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8)
|
surfaceRatio =
|
||||||
|
overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8)
|
||||||
} else {
|
} else {
|
||||||
overtimeStartAt = -1
|
overtimeStartAt = -1
|
||||||
}
|
}
|
||||||
@@ -670,17 +702,7 @@ function prepareBallSprite(src: string, level: number) {
|
|||||||
sprite.height = Math.max(1, Math.round(bounds.h * scale))
|
sprite.height = Math.max(1, Math.round(bounds.h * scale))
|
||||||
const ctx = sprite.getContext('2d')
|
const ctx = sprite.getContext('2d')
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
ctx.drawImage(
|
ctx.drawImage(image, bounds.x, bounds.y, bounds.w, bounds.h, 0, 0, sprite.width, sprite.height)
|
||||||
image,
|
|
||||||
bounds.x,
|
|
||||||
bounds.y,
|
|
||||||
bounds.w,
|
|
||||||
bounds.h,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
sprite.width,
|
|
||||||
sprite.height,
|
|
||||||
)
|
|
||||||
ballSprites[level] = sprite
|
ballSprites[level] = sprite
|
||||||
}
|
}
|
||||||
image.src = src
|
image.src = src
|
||||||
@@ -692,9 +714,7 @@ function drawMergePiece(piece: Piece) {
|
|||||||
const spawnProgress = Math.min(1, (elapsed - piece.spawnAt) / 0.22)
|
const spawnProgress = Math.min(1, (elapsed - piece.spawnAt) / 0.22)
|
||||||
const spawnScale = spawnProgress >= 1 ? 1 : easeOutBack(spawnProgress)
|
const spawnScale = spawnProgress >= 1 ? 1 : easeOutBack(spawnProgress)
|
||||||
const radius =
|
const radius =
|
||||||
piece.radius *
|
piece.radius * spawnScale * (isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
|
||||||
spawnScale *
|
|
||||||
(isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
|
|
||||||
context.save()
|
context.save()
|
||||||
context.translate(piece.x, piece.y)
|
context.translate(piece.x, piece.y)
|
||||||
const hue = (elapsed * 55) % 360
|
const hue = (elapsed * 55) % 360
|
||||||
@@ -792,9 +812,7 @@ function draw() {
|
|||||||
context.beginPath()
|
context.beginPath()
|
||||||
for (let x = 0; x <= width; x += 8) {
|
for (let x = 0; x <= width; x += 8) {
|
||||||
const waveY =
|
const waveY =
|
||||||
surfaceY +
|
surfaceY + Math.sin(x * 0.045 + elapsed * 1.8) * 2.5 + Math.sin(x * 0.013 + elapsed * 0.9) * 2
|
||||||
Math.sin(x * 0.045 + elapsed * 1.8) * 2.5 +
|
|
||||||
Math.sin(x * 0.013 + elapsed * 0.9) * 2
|
|
||||||
if (x === 0) context.moveTo(x, waveY)
|
if (x === 0) context.moveTo(x, waveY)
|
||||||
else context.lineTo(x, waveY)
|
else context.lineTo(x, waveY)
|
||||||
}
|
}
|
||||||
@@ -807,7 +825,12 @@ function draw() {
|
|||||||
const progress = Math.min(1, (elapsed - waveStart) / 0.8)
|
const progress = Math.min(1, (elapsed - waveStart) / 0.8)
|
||||||
const waveY = height * (1 - progress)
|
const waveY = height * (1 - progress)
|
||||||
const bandHeight = Math.max(22, height * 0.1)
|
const bandHeight = Math.max(22, height * 0.1)
|
||||||
const gradient = context.createLinearGradient(0, waveY - bandHeight, 0, waveY + bandHeight * 0.5)
|
const gradient = context.createLinearGradient(
|
||||||
|
0,
|
||||||
|
waveY - bandHeight,
|
||||||
|
0,
|
||||||
|
waveY + bandHeight * 0.5,
|
||||||
|
)
|
||||||
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)')
|
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)')
|
||||||
gradient.addColorStop(0.55, 'rgba(96, 178, 255, 0.5)')
|
gradient.addColorStop(0.55, 'rgba(96, 178, 255, 0.5)')
|
||||||
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
|
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
StyledInput,
|
StyledInput,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { save } from '@tauri-apps/plugin-dialog'
|
|
||||||
import { join } from '@tauri-apps/api/path'
|
import { join } from '@tauri-apps/api/path'
|
||||||
|
import { save } from '@tauri-apps/plugin-dialog'
|
||||||
import { readDir, stat } from '@tauri-apps/plugin-fs'
|
import { readDir, stat } from '@tauri-apps/plugin-fs'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import { process_listener } from '@/helpers/events'
|
|||||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
||||||
import { get_by_instance_id } from '@/helpers/process'
|
|
||||||
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
||||||
|
import { get_by_instance_id } from '@/helpers/process'
|
||||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ const checkProcess = async () => {
|
|||||||
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
|
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const play = async (e, context) => {
|
const play = async (e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
await run(props.instance.id).catch(async (err) => {
|
await run(props.instance.id).catch(async (err) => {
|
||||||
@@ -111,7 +111,7 @@ const play = async (e, context) => {
|
|||||||
await checkProcess()
|
await checkProcess()
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = async (e, context) => {
|
const stop = async (e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
await kill(props.instance.id).catch(handleError)
|
await kill(props.instance.id).catch(handleError)
|
||||||
await checkProcess()
|
await checkProcess()
|
||||||
@@ -205,7 +205,7 @@ onUnmounted(() => unlisten())
|
|||||||
: formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
|
: formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
|
||||||
"
|
"
|
||||||
:disabled="offline && !installed"
|
:disabled="offline && !installed"
|
||||||
@click="(e) => play(e, 'InstanceCard')"
|
@click="play"
|
||||||
@mousehover="checkProcess"
|
@mousehover="checkProcess"
|
||||||
>
|
>
|
||||||
<!-- Translate for optical centering -->
|
<!-- Translate for optical centering -->
|
||||||
@@ -295,7 +295,7 @@ onUnmounted(() => unlisten())
|
|||||||
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
|
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
|
||||||
!disabled,
|
!disabled,
|
||||||
}"
|
}"
|
||||||
@click="(e) => play(e, 'InstanceCard')"
|
@click="play"
|
||||||
@mousehover="checkProcess"
|
@mousehover="checkProcess"
|
||||||
>
|
>
|
||||||
<PlayIcon class="translate-x-[1px]" />
|
<PlayIcon class="translate-x-[1px]" />
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ async function copyShareUrl(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _openAIAnalysis(): void {
|
function openAIAnalysis(): void {
|
||||||
if (!lastAnalysis?.combined_log) {
|
if (!lastAnalysis?.combined_log) {
|
||||||
notifyNoLogContent()
|
notifyNoLogContent()
|
||||||
return
|
return
|
||||||
@@ -603,7 +603,7 @@ onUnmounted(() => {
|
|||||||
unlistenProcess?.()
|
unlistenProcess?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis: _openAIAnalysis })
|
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const messages = defineMessages({
|
|||||||
<template>
|
<template>
|
||||||
<iframe
|
<iframe
|
||||||
:src="skinSiteFrameUrl"
|
:src="skinSiteFrameUrl"
|
||||||
|
allow="clipboard-write; clipboard-read"
|
||||||
:title="formatMessage(messages.frameTitle)"
|
:title="formatMessage(messages.frameTitle)"
|
||||||
class="block h-full min-h-0 w-full border-0"
|
class="block h-full min-h-0 w-full border-0"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { clearHostedSession } from '@/helpers/hosted-packs'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
receiveSkinSiteMessage,
|
receiveSkinSiteMessage,
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
skinSiteStatus,
|
skinSiteStatus,
|
||||||
skinSiteUser,
|
skinSiteUser,
|
||||||
} from '@/composables/skin-site-session'
|
} from '@/composables/skin-site-session'
|
||||||
|
import { clearHostedSession } from '@/helpers/hosted-packs'
|
||||||
|
|
||||||
const frame = ref<HTMLIFrameElement>()
|
const frame = ref<HTMLIFrameElement>()
|
||||||
let lastMessage = 0
|
let lastMessage = 0
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ExternalIcon, UsersIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { Avatar, ButtonStyled, NewModal } from '@modrinth/ui'
|
||||||
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { teamMembers } from '@/data/about'
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
|
||||||
|
const SERVER_IPS: { label: string; host: string }[] = [
|
||||||
|
{ label: '直连 IPv4', host: 'slv4.starlight.cool' },
|
||||||
|
{ label: '直连 IPv6', host: 'slv6.starlight.cool' },
|
||||||
|
{ label: '北方代理 IPv4', host: 'north.starlight.cool' },
|
||||||
|
{ label: '南方代理 IPv4', host: 'south.starlight.cool' },
|
||||||
|
{ label: '美国代理 IPv4', host: 'usa.starlight.cool' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const RULES_URL = 'https://kdocs.cn/l/cmT6lVdryHJu'
|
||||||
|
const QQ_GROUP = '535392227'
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink(url: string) {
|
||||||
|
void openUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal ref="modal" header="欢迎使用 Starlight Launcher" width="640px" max-width="640px">
|
||||||
|
<div class="flex min-w-0 flex-col gap-5">
|
||||||
|
<!-- 1. 开发组 -->
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="m-0 flex items-center gap-2 text-sm font-semibold text-contrast">
|
||||||
|
<UsersIcon class="size-4 text-secondary" />
|
||||||
|
开发团队
|
||||||
|
</h3>
|
||||||
|
<p class="m-0 text-sm text-secondary">
|
||||||
|
本启动器由
|
||||||
|
<span class="font-semibold text-contrast">Ax_Tps</span>
|
||||||
|
和
|
||||||
|
<span class="font-semibold text-contrast">Disy920</span>
|
||||||
|
基于 Axolotl 开发。
|
||||||
|
</p>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<a
|
||||||
|
v-for="member in teamMembers"
|
||||||
|
:key="member.name"
|
||||||
|
:href="member.url ?? undefined"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
|
||||||
|
@click.prevent="member.url && openLink(member.url)"
|
||||||
|
>
|
||||||
|
<Avatar :src="member.avatarUrl" :alt="member.name" size="2.5rem" circle no-shadow />
|
||||||
|
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{ member.name }}</span>
|
||||||
|
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2. SLS 专属 -->
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="m-0 text-sm font-semibold text-contrast">Starlight 专用启动器</h3>
|
||||||
|
<p class="m-0 text-sm text-secondary">
|
||||||
|
该启动器为 Starlight 专用。如果您还未加入 StarLight,请前往我们的 QQ 审核群
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer border-0 bg-transparent p-0 font-semibold text-brand underline"
|
||||||
|
@click="openLink(`https://qm.qq.com/q/${QQ_GROUP}`)"
|
||||||
|
>
|
||||||
|
{{ QQ_GROUP }}
|
||||||
|
</button>
|
||||||
|
。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3. 服规更新 -->
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="m-0 text-sm font-semibold text-contrast">StarLight 总则章程更新</h3>
|
||||||
|
<p class="m-0 text-sm text-secondary">
|
||||||
|
StarLight 总则章程(即“服规”)更新至第 15 版。该版服规进一步完善与严格了聚落建立、改组、招新与运营的全链条要求,请大家注意阅读并遵守!本版服规自
|
||||||
|
2026 年 9 月 27 日 0 点起开始试行。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-fit cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-sm font-semibold text-brand underline"
|
||||||
|
@click="openLink(RULES_URL)"
|
||||||
|
>
|
||||||
|
查看服规链接
|
||||||
|
<ExternalIcon class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4. 服务器连接 -->
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="m-0 text-sm font-semibold text-contrast">服务器连接</h3>
|
||||||
|
<p class="m-0 text-sm text-secondary">服务器目前下列几项 IP 可用:</p>
|
||||||
|
<ul class="m-0 flex flex-col gap-1 pl-5 text-sm text-secondary">
|
||||||
|
<li v-for="ip in SERVER_IPS" :key="ip.host">
|
||||||
|
<span class="text-contrast">{{ ip.label }}</span>
|
||||||
|
:<code class="text-brand">{{ ip.host }}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="m-0 text-sm text-secondary">
|
||||||
|
请注意,我们优先推荐您尝试直连 IP(包括 v4 和 v6,哪个可用用哪个)。若没有出现高延迟或延迟波动严重的问题,尽量避免使用代理
|
||||||
|
IP(代理 IP 是流量计费的,使用会给服务器带来更大的经济压力)。
|
||||||
|
</p>
|
||||||
|
<p class="m-0 text-sm text-secondary">
|
||||||
|
若必须使用代理 IP,按以往经验,河北(含)以北的我国省份,在选用代理 IP 时建议优先选用北方代理,否则请使用南方代理。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button @click="hide">
|
||||||
|
<XIcon />
|
||||||
|
我知道了
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
@@ -61,7 +61,7 @@ const historyAnnouncements = computed(() =>
|
|||||||
<UpdateAnnouncementContent
|
<UpdateAnnouncementContent
|
||||||
:announcement="currentAnnouncement"
|
:announcement="currentAnnouncement"
|
||||||
:version="currentVersion"
|
:version="currentVersion"
|
||||||
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.website"
|
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.releasesUrl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ const historyAnnouncements = computed(() =>
|
|||||||
<UpdateAnnouncementContent
|
<UpdateAnnouncementContent
|
||||||
:announcement="announcement"
|
:announcement="announcement"
|
||||||
:show-header="false"
|
:show-header="false"
|
||||||
:external-url="announcement.externalUrl"
|
:external-url="announcement.externalUrl ?? `${AxolotlBrandConfig.releasesUrl}/tag/v${announcement.version}`"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { FileIcon, FolderIcon, MinusIcon, PlusIcon } from '@modrinth/assets'
|
||||||
|
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BackupExclusion,
|
||||||
|
type BackupExclusionKind,
|
||||||
|
canonicalizeBackupExclusions,
|
||||||
|
formatBackupExclusionPath,
|
||||||
|
normalizeBackupExclusion,
|
||||||
|
} from '@/helpers/instance-backup'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
instanceId: string
|
||||||
|
instanceRoot: string
|
||||||
|
modelValue: BackupExclusion[]
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: BackupExclusion[]] }>()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
const picking = ref<BackupExclusionKind | null>(null)
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
empty: {
|
||||||
|
id: 'instance.backups.exclusions.empty',
|
||||||
|
defaultMessage: 'No files or folders are excluded.',
|
||||||
|
},
|
||||||
|
addFile: {
|
||||||
|
id: 'instance.backups.exclusions.add-file',
|
||||||
|
defaultMessage: 'Add excluded file',
|
||||||
|
},
|
||||||
|
addFolder: {
|
||||||
|
id: 'instance.backups.exclusions.add-folder',
|
||||||
|
defaultMessage: 'Add excluded folder',
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
id: 'instance.backups.exclusions.remove',
|
||||||
|
defaultMessage: 'Remove {path} from exclusions',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function pickedPath(result: unknown): string | null {
|
||||||
|
if (typeof result === 'string') return result
|
||||||
|
if (result && typeof result === 'object' && 'path' in result) {
|
||||||
|
const path = (result as { path?: unknown }).path
|
||||||
|
return typeof path === 'string' ? path : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addExclusion(kind: BackupExclusionKind) {
|
||||||
|
if (props.disabled || picking.value) return
|
||||||
|
picking.value = kind
|
||||||
|
try {
|
||||||
|
const result = await open({
|
||||||
|
directory: kind === 'directory',
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: props.instanceRoot,
|
||||||
|
})
|
||||||
|
const selectedPath = pickedPath(result)
|
||||||
|
if (!selectedPath) return
|
||||||
|
const exclusion = await normalizeBackupExclusion(props.instanceId, selectedPath, kind)
|
||||||
|
emit('update:modelValue', canonicalizeBackupExclusions([...props.modelValue, exclusion]))
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
picking.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExclusion(path: string) {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
props.modelValue.filter((exclusion) => exclusion.path !== path),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<p v-if="modelValue.length === 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
{{ formatMessage(messages.empty) }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="exclusion in modelValue"
|
||||||
|
:key="exclusion.path"
|
||||||
|
class="flex min-h-11 items-center gap-3 px-3 py-2"
|
||||||
|
>
|
||||||
|
<FolderIcon
|
||||||
|
v-if="exclusion.kind === 'directory'"
|
||||||
|
class="size-5 shrink-0 text-[var(--color-text-tertiary)]"
|
||||||
|
/>
|
||||||
|
<FileIcon v-else class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
|
<code class="min-w-0 flex-1 truncate text-sm text-[var(--color-text-primary)]">{{
|
||||||
|
formatBackupExclusionPath(exclusion)
|
||||||
|
}}</code>
|
||||||
|
<Button
|
||||||
|
type="quiet"
|
||||||
|
size="2xs"
|
||||||
|
circular
|
||||||
|
icon-only
|
||||||
|
:disabled="disabled || picking !== null"
|
||||||
|
:aria-label="
|
||||||
|
formatMessage(messages.remove, { path: formatBackupExclusionPath(exclusion) })
|
||||||
|
"
|
||||||
|
@click="removeExclusion(exclusion.path)"
|
||||||
|
><MinusIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button type="outlined" :disabled="disabled || picking !== null" @click="addExclusion('file')"
|
||||||
|
><PlusIcon />
|
||||||
|
{{ formatMessage(messages.addFile) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="outlined"
|
||||||
|
:disabled="disabled || picking !== null"
|
||||||
|
@click="addExclusion('directory')"
|
||||||
|
><PlusIcon />
|
||||||
|
{{ formatMessage(messages.addFolder) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { FileArchiveIcon, SaveIcon, TrashIcon, UndoIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Admonition,
|
||||||
|
Button,
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
injectNotificationManager,
|
||||||
|
NewModal,
|
||||||
|
useFormatBytes,
|
||||||
|
useFormatDateTime,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { get_full_path } from '@/helpers/instance'
|
||||||
|
import {
|
||||||
|
type BackupExclusion,
|
||||||
|
type BackupOperation,
|
||||||
|
type BackupOperationState,
|
||||||
|
type BackupProgressStage,
|
||||||
|
type BackupRestorePreview,
|
||||||
|
type BackupSnapshot,
|
||||||
|
cancelBackup,
|
||||||
|
deleteBackup,
|
||||||
|
disableBackups,
|
||||||
|
enableBackups,
|
||||||
|
getBackupConfig,
|
||||||
|
type InstanceBackupConfig,
|
||||||
|
type InstanceBackupEligibility,
|
||||||
|
listBackupOperations,
|
||||||
|
listBackups,
|
||||||
|
listenBackupProgress,
|
||||||
|
restoreBackup,
|
||||||
|
startBackup,
|
||||||
|
startBackupRestorePreview,
|
||||||
|
updateBackupExclusions,
|
||||||
|
} from '@/helpers/instance-backup'
|
||||||
|
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||||
|
|
||||||
|
import BackupExclusionSelector from './BackupExclusionSelector.vue'
|
||||||
|
|
||||||
|
const { instance } = injectInstanceSettings()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const formatBytes = useFormatBytes()
|
||||||
|
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'instance.backups.title', defaultMessage: 'Instance backups' },
|
||||||
|
description: {
|
||||||
|
id: 'instance.backups.description',
|
||||||
|
defaultMessage: 'Create deduplicated snapshots of the whole instance while it is closed.',
|
||||||
|
},
|
||||||
|
loading: { id: 'instance.backups.loading', defaultMessage: 'Loading backup settings...' },
|
||||||
|
unavailable: { id: 'instance.backups.unavailable', defaultMessage: 'Backups are unavailable' },
|
||||||
|
notInstalled: {
|
||||||
|
id: 'instance.backups.unavailable.not-installed',
|
||||||
|
defaultMessage: 'Finish installing this instance before enabling backups.',
|
||||||
|
},
|
||||||
|
symlinkInstance: {
|
||||||
|
id: 'instance.backups.unavailable.symlink',
|
||||||
|
defaultMessage: 'Imported symbolic-link instances cannot be backed up.',
|
||||||
|
},
|
||||||
|
directLinked: {
|
||||||
|
id: 'instance.backups.unavailable.direct-linked',
|
||||||
|
defaultMessage: 'Instances linked directly to another launcher cannot be backed up.',
|
||||||
|
},
|
||||||
|
externalDirectory: {
|
||||||
|
id: 'instance.backups.unavailable.external-directory',
|
||||||
|
defaultMessage: 'Instances that use an external game directory cannot be backed up.',
|
||||||
|
},
|
||||||
|
rootMissing: {
|
||||||
|
id: 'instance.backups.unavailable.root-missing',
|
||||||
|
defaultMessage: 'The instance folder is missing.',
|
||||||
|
},
|
||||||
|
rootLink: {
|
||||||
|
id: 'instance.backups.unavailable.root-link',
|
||||||
|
defaultMessage: 'Instances whose root folder is a link cannot be backed up.',
|
||||||
|
},
|
||||||
|
choose: {
|
||||||
|
id: 'instance.backups.choose',
|
||||||
|
defaultMessage:
|
||||||
|
'Everything is backed up by default. Add files or folders that should be excluded.',
|
||||||
|
},
|
||||||
|
enable: { id: 'instance.backups.enable', defaultMessage: 'Enable backups' },
|
||||||
|
editExclusions: { id: 'instance.backups.edit-exclusions', defaultMessage: 'Edit exclusions' },
|
||||||
|
saveExclusions: {
|
||||||
|
id: 'instance.backups.save-exclusions',
|
||||||
|
defaultMessage: 'Save exclusions',
|
||||||
|
},
|
||||||
|
allIncluded: {
|
||||||
|
id: 'instance.backups.all-included',
|
||||||
|
defaultMessage: 'All instance files and folders are included.',
|
||||||
|
},
|
||||||
|
excludedPaths: {
|
||||||
|
id: 'instance.backups.excluded-paths',
|
||||||
|
defaultMessage: 'Excluded: {paths}',
|
||||||
|
},
|
||||||
|
backupNow: { id: 'instance.backups.backup-now', defaultMessage: 'Back up now' },
|
||||||
|
cancelBackup: { id: 'instance.backups.cancel', defaultMessage: 'Cancel backup' },
|
||||||
|
stageScanning: {
|
||||||
|
id: 'instance.backups.stage.scanning',
|
||||||
|
defaultMessage: 'Scanning files',
|
||||||
|
},
|
||||||
|
stageHashing: { id: 'instance.backups.stage.hashing', defaultMessage: 'Hashing files' },
|
||||||
|
stageSaving: { id: 'instance.backups.stage.saving', defaultMessage: 'Saving snapshot index' },
|
||||||
|
stageQueued: { id: 'instance.backups.stage.queued', defaultMessage: 'Waiting to start' },
|
||||||
|
stageValidating: {
|
||||||
|
id: 'instance.backups.stage.validating',
|
||||||
|
defaultMessage: 'Validating restore',
|
||||||
|
},
|
||||||
|
stageMaterializing: {
|
||||||
|
id: 'instance.backups.stage.materializing',
|
||||||
|
defaultMessage: 'Preparing changed files',
|
||||||
|
},
|
||||||
|
stageApplying: {
|
||||||
|
id: 'instance.backups.stage.applying',
|
||||||
|
defaultMessage: 'Applying changed files',
|
||||||
|
},
|
||||||
|
stagePreview: {
|
||||||
|
id: 'instance.backups.stage.preview',
|
||||||
|
defaultMessage: 'Analyzing restore changes',
|
||||||
|
},
|
||||||
|
progressBytes: {
|
||||||
|
id: 'instance.backups.progress-bytes',
|
||||||
|
defaultMessage: '{processed} of {total}',
|
||||||
|
},
|
||||||
|
stageFailed: { id: 'instance.backups.stage.failed', defaultMessage: 'Backup failed' },
|
||||||
|
stageInterrupted: {
|
||||||
|
id: 'instance.backups.stage.interrupted',
|
||||||
|
defaultMessage: 'Backup operation was interrupted',
|
||||||
|
},
|
||||||
|
snapshots: { id: 'instance.backups.snapshots', defaultMessage: 'Snapshots' },
|
||||||
|
empty: { id: 'instance.backups.empty', defaultMessage: 'No backups have been created yet.' },
|
||||||
|
snapshotMeta: {
|
||||||
|
id: 'instance.backups.snapshot-meta',
|
||||||
|
defaultMessage: '{files, plural, one {# file} other {# files}} · {size}',
|
||||||
|
},
|
||||||
|
addedSize: { id: 'instance.backups.added-size', defaultMessage: '{size} newly stored' },
|
||||||
|
restore: { id: 'instance.backups.restore', defaultMessage: 'Restore backup' },
|
||||||
|
delete: { id: 'instance.backups.delete', defaultMessage: 'Delete backup' },
|
||||||
|
disable: { id: 'instance.backups.disable', defaultMessage: 'Disable backups' },
|
||||||
|
disableBlocked: {
|
||||||
|
id: 'instance.backups.disable-blocked',
|
||||||
|
defaultMessage: 'Delete every snapshot before disabling backups.',
|
||||||
|
},
|
||||||
|
restoreTitle: { id: 'instance.backups.restore-title', defaultMessage: 'Restore this backup?' },
|
||||||
|
restoreBody: {
|
||||||
|
id: 'instance.backups.restore-body',
|
||||||
|
defaultMessage:
|
||||||
|
'Upcoming operations: add {added, plural, one {# file} other {# files}}, modify {modified, plural, one {# file} other {# files}}, and delete {deleted, plural, one {# file} other {# files}}. Continue?',
|
||||||
|
},
|
||||||
|
deleteTitle: { id: 'instance.backups.delete-title', defaultMessage: 'Delete this backup?' },
|
||||||
|
deleteBody: {
|
||||||
|
id: 'instance.backups.delete-body',
|
||||||
|
defaultMessage:
|
||||||
|
'This snapshot will be permanently deleted. Unused stored files will also be removed.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = ref<InstanceBackupConfig | null>(null)
|
||||||
|
const snapshots = ref<BackupSnapshot[]>([])
|
||||||
|
const instanceRoot = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const editingExclusions = ref(false)
|
||||||
|
const excludedPaths = ref<BackupExclusion[]>([])
|
||||||
|
const action = ref<string | null>(null)
|
||||||
|
const operation = ref<BackupOperation | null>(null)
|
||||||
|
const selectedSnapshot = ref<BackupSnapshot | null>(null)
|
||||||
|
const restorePreview = ref<BackupRestorePreview | null>(null)
|
||||||
|
const deleteModal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const restoreModal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
|
const eligible = computed(() => config.value?.eligibility === 'eligible')
|
||||||
|
const terminalStates: BackupOperationState[] = ['completed', 'cancelled', 'failed', 'interrupted']
|
||||||
|
const isRunning = computed(
|
||||||
|
() => operation.value !== null && !terminalStates.includes(operation.value.state),
|
||||||
|
)
|
||||||
|
const canCancel = computed(() => operation.value?.cancellable === true)
|
||||||
|
const operationLabel = computed(() => {
|
||||||
|
if (operation.value?.operation_type === 'restore_preview') {
|
||||||
|
return formatMessage(messages.stagePreview)
|
||||||
|
}
|
||||||
|
switch (operation.value?.state) {
|
||||||
|
case 'queued':
|
||||||
|
return formatMessage(messages.stageQueued)
|
||||||
|
case 'hashing':
|
||||||
|
return formatMessage(messages.stageHashing)
|
||||||
|
case 'saving':
|
||||||
|
return formatMessage(messages.stageSaving)
|
||||||
|
case 'validating':
|
||||||
|
return formatMessage(messages.stageValidating)
|
||||||
|
case 'materializing':
|
||||||
|
return formatMessage(messages.stageMaterializing)
|
||||||
|
case 'applying':
|
||||||
|
return formatMessage(messages.stageApplying)
|
||||||
|
default:
|
||||||
|
return formatMessage(messages.stageScanning)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function operationFromEvent(event: {
|
||||||
|
operationId: string
|
||||||
|
operationType: BackupOperation['operation_type']
|
||||||
|
instanceId?: string
|
||||||
|
stage: BackupProgressStage
|
||||||
|
processedBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
snapshotId?: string
|
||||||
|
message?: string
|
||||||
|
}): BackupOperation {
|
||||||
|
const now = Date.now()
|
||||||
|
const state: BackupOperationState =
|
||||||
|
event.stage === 'restoring' || event.stage === 'copying'
|
||||||
|
? 'materializing'
|
||||||
|
: event.stage === 'deleting'
|
||||||
|
? 'queued'
|
||||||
|
: event.stage
|
||||||
|
return {
|
||||||
|
id: event.operationId,
|
||||||
|
operation_type: event.operationType,
|
||||||
|
instance_id: event.instanceId ?? instance.value.id,
|
||||||
|
snapshot_id: event.snapshotId,
|
||||||
|
state,
|
||||||
|
processed_bytes: event.processedBytes,
|
||||||
|
total_bytes: event.totalBytes,
|
||||||
|
cancellable: event.stage === 'scanning' || event.stage === 'hashing',
|
||||||
|
cancel_requested: false,
|
||||||
|
error: event.message,
|
||||||
|
created_at: operation.value?.id === event.operationId ? operation.value.created_at : now,
|
||||||
|
updated_at: now,
|
||||||
|
finished_at: ['completed', 'cancelled', 'failed'].includes(event.stage) ? now : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOperation() {
|
||||||
|
const operations = await listBackupOperations(instance.value.id)
|
||||||
|
const latest = operations[0] ?? null
|
||||||
|
operation.value =
|
||||||
|
latest && (isRunningState(latest.state) || ['failed', 'interrupted'].includes(latest.state))
|
||||||
|
? latest
|
||||||
|
: null
|
||||||
|
if (operation.value?.operation_type === 'restore_preview' && operation.value.snapshot_id) {
|
||||||
|
selectedSnapshot.value =
|
||||||
|
snapshots.value.find((snapshot) => snapshot.id === operation.value?.snapshot_id) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunningState(state: BackupOperationState) {
|
||||||
|
return !terminalStates.includes(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibilityMessages: Record<
|
||||||
|
Exclude<InstanceBackupEligibility, 'eligible'>,
|
||||||
|
keyof typeof messages
|
||||||
|
> = {
|
||||||
|
not_installed: 'notInstalled',
|
||||||
|
symlink_instance: 'symlinkInstance',
|
||||||
|
direct_linked_instance: 'directLinked',
|
||||||
|
external_game_directory: 'externalDirectory',
|
||||||
|
root_missing: 'rootMissing',
|
||||||
|
root_is_link: 'rootLink',
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailableReason = computed(() => {
|
||||||
|
const eligibility = config.value?.eligibility
|
||||||
|
if (!eligibility || eligibility === 'eligible') return ''
|
||||||
|
return formatMessage(messages[eligibilityMessages[eligibility]])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const nextConfig = await getBackupConfig(instance.value.id)
|
||||||
|
config.value = nextConfig
|
||||||
|
excludedPaths.value = nextConfig.enabled ? [...nextConfig.excluded_paths] : []
|
||||||
|
const [nextRoot, nextSnapshots] = await Promise.all([
|
||||||
|
nextConfig.eligibility === 'eligible' ? get_full_path(instance.value.id) : '',
|
||||||
|
listBackups(instance.value.id),
|
||||||
|
])
|
||||||
|
instanceRoot.value = nextRoot
|
||||||
|
snapshots.value = nextSnapshots
|
||||||
|
await refreshOperation()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(name: string, task: () => Promise<void>) {
|
||||||
|
action.value = name
|
||||||
|
try {
|
||||||
|
await task()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
action.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enable() {
|
||||||
|
void runAction('enable', async () => {
|
||||||
|
config.value = await enableBackups(instance.value.id, excludedPaths.value)
|
||||||
|
editingExclusions.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginEditingExclusions() {
|
||||||
|
excludedPaths.value = [...(config.value?.excluded_paths ?? [])]
|
||||||
|
editingExclusions.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveExclusions() {
|
||||||
|
void runAction('exclusions', async () => {
|
||||||
|
config.value = await updateBackupExclusions(instance.value.id, excludedPaths.value)
|
||||||
|
editingExclusions.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
void runAction('start', async () => {
|
||||||
|
const id = await startBackup(instance.value.id)
|
||||||
|
operation.value = {
|
||||||
|
id,
|
||||||
|
operation_type: 'create',
|
||||||
|
instance_id: instance.value.id,
|
||||||
|
state: 'queued',
|
||||||
|
processed_bytes: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
cancellable: true,
|
||||||
|
cancel_requested: false,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
if (!operation.value || !canCancel.value) return
|
||||||
|
void runAction('cancel', async () => {
|
||||||
|
if (await cancelBackup(operation.value!.id)) {
|
||||||
|
operation.value = {
|
||||||
|
...operation.value!,
|
||||||
|
cancellable: false,
|
||||||
|
cancel_requested: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(snapshot: BackupSnapshot) {
|
||||||
|
selectedSnapshot.value = snapshot
|
||||||
|
deleteModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRestore(snapshot: BackupSnapshot) {
|
||||||
|
void runAction(`preview:${snapshot.id}`, async () => {
|
||||||
|
selectedSnapshot.value = snapshot
|
||||||
|
const id = await startBackupRestorePreview(snapshot.id)
|
||||||
|
operation.value = {
|
||||||
|
id,
|
||||||
|
operation_type: 'restore_preview',
|
||||||
|
instance_id: instance.value.id,
|
||||||
|
snapshot_id: snapshot.id,
|
||||||
|
state: 'queued',
|
||||||
|
processed_bytes: 0,
|
||||||
|
total_bytes: snapshot.logical_size,
|
||||||
|
cancellable: true,
|
||||||
|
cancel_requested: false,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSnapshot() {
|
||||||
|
if (!selectedSnapshot.value) return
|
||||||
|
void runAction(`delete:${selectedSnapshot.value.id}`, async () => {
|
||||||
|
await deleteBackup(selectedSnapshot.value!.id)
|
||||||
|
await refresh()
|
||||||
|
deleteModal.value?.hide()
|
||||||
|
selectedSnapshot.value = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSnapshot() {
|
||||||
|
if (!selectedSnapshot.value || !restorePreview.value) return
|
||||||
|
void runAction(`restore:${selectedSnapshot.value.id}`, async () => {
|
||||||
|
const id = await restoreBackup(selectedSnapshot.value!.id, restorePreview.value!.plan_token)
|
||||||
|
operation.value = {
|
||||||
|
id,
|
||||||
|
operation_type: 'restore',
|
||||||
|
instance_id: instance.value.id,
|
||||||
|
snapshot_id: selectedSnapshot.value!.id,
|
||||||
|
state: 'queued',
|
||||||
|
processed_bytes: 0,
|
||||||
|
total_bytes: selectedSnapshot.value!.logical_size,
|
||||||
|
cancellable: true,
|
||||||
|
cancel_requested: false,
|
||||||
|
created_at: Date.now(),
|
||||||
|
updated_at: Date.now(),
|
||||||
|
}
|
||||||
|
restoreModal.value?.hide()
|
||||||
|
selectedSnapshot.value = null
|
||||||
|
restorePreview.value = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable() {
|
||||||
|
void runAction('disable', async () => {
|
||||||
|
await disableBackups(instance.value.id)
|
||||||
|
await refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
unlisten = await listenBackupProgress(async (event) => {
|
||||||
|
if (
|
||||||
|
!['create', 'restore', 'restore_preview'].includes(event.operationType) ||
|
||||||
|
event.instanceId !== instance.value.id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
operation.value = operationFromEvent(event)
|
||||||
|
if (event.operationType === 'restore_preview' && event.stage === 'completed') {
|
||||||
|
const completed = (await listBackupOperations(instance.value.id)).find(
|
||||||
|
(candidate) => candidate.id === event.operationId,
|
||||||
|
)
|
||||||
|
const snapshot = snapshots.value.find((candidate) => candidate.id === event.snapshotId)
|
||||||
|
if (completed?.restore_preview && snapshot) {
|
||||||
|
selectedSnapshot.value = snapshot
|
||||||
|
restorePreview.value = completed.restore_preview
|
||||||
|
operation.value = null
|
||||||
|
restoreModal.value?.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (['completed', 'cancelled', 'failed'].includes(event.stage)) {
|
||||||
|
void refresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => unlisten?.())
|
||||||
|
watch(
|
||||||
|
() => instance.value.id,
|
||||||
|
() => void refresh(),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<header>
|
||||||
|
<h2 class="m-0 text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{{ formatMessage(messages.title) }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 mt-1 text-[var(--color-text-tertiary)]">
|
||||||
|
{{ formatMessage(messages.description) }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="loading" class="m-0 text-[var(--color-text-tertiary)]">
|
||||||
|
{{ formatMessage(messages.loading) }}
|
||||||
|
</p>
|
||||||
|
<Admonition v-else-if="!eligible" type="warning" :header="formatMessage(messages.unavailable)">
|
||||||
|
{{ unavailableReason }}
|
||||||
|
</Admonition>
|
||||||
|
<template v-else-if="config">
|
||||||
|
<div v-if="!config.enabled || editingExclusions" class="flex flex-col gap-4">
|
||||||
|
<p class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.choose) }}</p>
|
||||||
|
<BackupExclusionSelector
|
||||||
|
v-model="excludedPaths"
|
||||||
|
:instance-id="instance.id"
|
||||||
|
:instance-root="instanceRoot"
|
||||||
|
:disabled="action !== null"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button :disabled="action !== null" @click="config.enabled ? saveExclusions() : enable()"
|
||||||
|
><SaveIcon />
|
||||||
|
{{ formatMessage(config.enabled ? messages.saveExclusions : messages.enable) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="config.enabled"
|
||||||
|
type="outlined"
|
||||||
|
:disabled="action !== null"
|
||||||
|
@click="editingExclusions = false"
|
||||||
|
><XIcon />
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-2 rounded-lg border border-solid border-surface-4 p-3">
|
||||||
|
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
{{
|
||||||
|
config.excluded_paths.length === 0
|
||||||
|
? formatMessage(messages.allIncluded)
|
||||||
|
: formatMessage(messages.excludedPaths, {
|
||||||
|
paths: config.excluded_paths.map((entry) => entry.path).join(', '),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button :disabled="isRunning || action !== null" @click="start"
|
||||||
|
><FileArchiveIcon />
|
||||||
|
{{ formatMessage(messages.backupNow) }}
|
||||||
|
</Button>
|
||||||
|
<Button v-if="canCancel" type="outlined" :disabled="action !== null" @click="cancel"
|
||||||
|
><XIcon />
|
||||||
|
{{ formatMessage(messages.cancelBackup) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="outlined"
|
||||||
|
:disabled="isRunning || action !== null"
|
||||||
|
@click="beginEditingExclusions"
|
||||||
|
>{{ formatMessage(messages.editExclusions) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-if="isRunning" class="m-0 text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
|
{{ operationLabel }}
|
||||||
|
<span
|
||||||
|
v-if="(operation?.total_bytes ?? 0) > 0"
|
||||||
|
class="ml-2 text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
formatMessage(messages.progressBytes, {
|
||||||
|
processed: formatBytes(operation?.processed_bytes ?? 0),
|
||||||
|
total: formatBytes(operation?.total_bytes ?? 0),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="operation?.state === 'failed' || operation?.state === 'interrupted'"
|
||||||
|
class="m-0 text-sm text-red"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
formatMessage(
|
||||||
|
operation.state === 'interrupted'
|
||||||
|
? messages.stageInterrupted
|
||||||
|
: messages.stageFailed,
|
||||||
|
)
|
||||||
|
}}<span v-if="operation.error">: {{ operation.error }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h3 class="m-0 text-base font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{{ formatMessage(messages.snapshots) }}
|
||||||
|
</h3>
|
||||||
|
<p v-if="snapshots.length === 0" class="m-0 text-[var(--color-text-tertiary)]">
|
||||||
|
{{ formatMessage(messages.empty) }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="snapshot in snapshots"
|
||||||
|
:key="snapshot.id"
|
||||||
|
class="flex items-center gap-3 px-3 py-3"
|
||||||
|
>
|
||||||
|
<FileArchiveIcon class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="m-0 font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{{ formatDate(new Date(snapshot.created_at)) }}
|
||||||
|
</p>
|
||||||
|
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
{{
|
||||||
|
formatMessage(messages.snapshotMeta, {
|
||||||
|
files: snapshot.file_count,
|
||||||
|
size: formatBytes(snapshot.logical_size),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
·
|
||||||
|
{{
|
||||||
|
formatMessage(messages.addedSize, { size: formatBytes(snapshot.added_size) })
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="quiet"
|
||||||
|
size="2xs"
|
||||||
|
circular
|
||||||
|
icon-only
|
||||||
|
:disabled="action !== null || isRunning"
|
||||||
|
:aria-label="formatMessage(messages.restore)"
|
||||||
|
@click="confirmRestore(snapshot)"
|
||||||
|
><UndoIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="quiet"
|
||||||
|
color="red"
|
||||||
|
size="2xs"
|
||||||
|
circular
|
||||||
|
icon-only
|
||||||
|
:disabled="action !== null || isRunning"
|
||||||
|
:aria-label="formatMessage(messages.delete)"
|
||||||
|
@click="confirmDelete(snapshot)"
|
||||||
|
><TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start gap-2">
|
||||||
|
<Button
|
||||||
|
type="outlined"
|
||||||
|
color="red"
|
||||||
|
:disabled="snapshots.length > 0 || action !== null || isRunning"
|
||||||
|
@click="disable"
|
||||||
|
>{{ formatMessage(messages.disable) }}
|
||||||
|
</Button>
|
||||||
|
<p v-if="snapshots.length > 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
|
||||||
|
{{ formatMessage(messages.disableBlocked) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<NewModal
|
||||||
|
ref="deleteModal"
|
||||||
|
:header="formatMessage(messages.deleteTitle)"
|
||||||
|
fade="danger"
|
||||||
|
max-width="500px"
|
||||||
|
>
|
||||||
|
<Admonition type="critical">{{ formatMessage(messages.deleteBody) }}</Admonition>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="outlined"
|
||||||
|
:disabled="action?.startsWith('delete:')"
|
||||||
|
@click="deleteModal?.hide()"
|
||||||
|
>{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="red"
|
||||||
|
:disabled="action?.startsWith('delete:')"
|
||||||
|
@click="removeSnapshot"
|
||||||
|
><TrashIcon />{{ formatMessage(messages.delete) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
<NewModal ref="restoreModal" :header="formatMessage(messages.restoreTitle)" max-width="500px">
|
||||||
|
<Admonition v-if="restorePreview" type="warning">
|
||||||
|
{{
|
||||||
|
formatMessage(messages.restoreBody, {
|
||||||
|
added: restorePreview.added_files,
|
||||||
|
modified: restorePreview.modified_files,
|
||||||
|
deleted: restorePreview.deleted_files,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Admonition>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="outlined"
|
||||||
|
:disabled="action?.startsWith('restore:')"
|
||||||
|
@click="restoreModal?.hide()"
|
||||||
|
>{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</Button>
|
||||||
|
<Button :disabled="action?.startsWith('restore:')" @click="restoreSnapshot"
|
||||||
|
><UndoIcon />{{ formatMessage(messages.restore) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { EditIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
IconButton,
|
||||||
|
injectNotificationManager,
|
||||||
|
Toggle,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { computed, nextTick, onScopeDispose, ref } from 'vue'
|
||||||
|
|
||||||
|
import { gameSettingsKeys } from '@/helpers/game-options'
|
||||||
|
import {
|
||||||
|
type GlobalSyncedOptions,
|
||||||
|
isSyncedOptionAvailable,
|
||||||
|
set_global_synced_option,
|
||||||
|
type SyncedOption,
|
||||||
|
} from '@/helpers/instance'
|
||||||
|
import {
|
||||||
|
canSourceMultiplayerServers,
|
||||||
|
gameOptionsSyncSourcesQueryOptions,
|
||||||
|
globalSyncedOptionsQueryOptions,
|
||||||
|
initializedSyncedOptionsQueryOptions,
|
||||||
|
syncedOptionsKeys,
|
||||||
|
syncedServersQueryOptions,
|
||||||
|
} from '@/helpers/synced-options'
|
||||||
|
import { syncedPackKeys, syncedPackQueryOptions } from '@/helpers/synced-packs'
|
||||||
|
import { instanceKeys, instanceListQueryOptions } from '@/pages/instance/query-options'
|
||||||
|
|
||||||
|
import SettingsRow from '../settings/SettingsRow.vue'
|
||||||
|
import SettingsSection from '../settings/SettingsSection.vue'
|
||||||
|
import CommandHistoryModal from '../settings/instances/command-history-modal.vue'
|
||||||
|
import SyncedServersModal from '../settings/instances/servers-modal.vue'
|
||||||
|
import SyncedPacksModal from '../settings/instances/SyncedPacksModal.vue'
|
||||||
|
import SyncSourceModal from '../settings/instances/SyncSourceModal.vue'
|
||||||
|
import GameSettingsModal from '../settings/instances/game-settings-modal/index.vue'
|
||||||
|
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const syncedPacksModal = ref<InstanceType<typeof SyncedPacksModal>>()
|
||||||
|
const commandHistoryModal = ref<InstanceType<typeof CommandHistoryModal>>()
|
||||||
|
const syncedServersModal = ref<InstanceType<typeof SyncedServersModal>>()
|
||||||
|
const gameSettingsModal = ref<InstanceType<typeof GameSettingsModal> | null>(null)
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
globalOptionsTitle: {
|
||||||
|
id: 'app.settings.synced-options.title',
|
||||||
|
defaultMessage: 'Options synced across all instances',
|
||||||
|
},
|
||||||
|
resourcePacks: {
|
||||||
|
id: 'app.settings.synced-options.resource-packs',
|
||||||
|
defaultMessage: 'Sync resource packs',
|
||||||
|
},
|
||||||
|
resourcePacksDescription: {
|
||||||
|
id: 'app.settings.synced-options.resource-packs.description',
|
||||||
|
defaultMessage: 'Use the same resource packs across your instances.',
|
||||||
|
},
|
||||||
|
dataPacks: { id: 'app.settings.synced-options.data-packs', defaultMessage: 'Sync data packs' },
|
||||||
|
dataPacksDescription: {
|
||||||
|
id: 'app.settings.synced-options.data-packs.description',
|
||||||
|
defaultMessage: 'Use the same datapacks across your instances',
|
||||||
|
},
|
||||||
|
packsDisabled: {
|
||||||
|
id: 'app.settings.synced-options.packs.disabled',
|
||||||
|
defaultMessage: 'Enable syncing before editing shared packs.',
|
||||||
|
},
|
||||||
|
gameSettings: {
|
||||||
|
id: 'app.settings.synced-options.game-settings',
|
||||||
|
defaultMessage: 'Sync game options',
|
||||||
|
},
|
||||||
|
gameSettingsDescription: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.description',
|
||||||
|
defaultMessage: 'Use the same game options across your instances.',
|
||||||
|
},
|
||||||
|
gameSettingsButton: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.button',
|
||||||
|
defaultMessage: 'Edit game settings',
|
||||||
|
},
|
||||||
|
gameSettingsDisabledTooltip: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.disabled-tooltip',
|
||||||
|
defaultMessage: 'Enable game settings sync before choosing individual settings.',
|
||||||
|
},
|
||||||
|
noGameOptionsToEdit: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.no-options-to-edit',
|
||||||
|
defaultMessage: "You haven't got any options yet to edit",
|
||||||
|
},
|
||||||
|
multiplayerServers: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers',
|
||||||
|
defaultMessage: 'Sync multiplayer servers',
|
||||||
|
},
|
||||||
|
multiplayerServersDescription: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.description',
|
||||||
|
defaultMessage: 'Use the same multiplayer servers across your instances.',
|
||||||
|
},
|
||||||
|
commandHistory: {
|
||||||
|
id: 'app.settings.synced-options.command-history',
|
||||||
|
defaultMessage: 'Sync command history',
|
||||||
|
},
|
||||||
|
commandHistoryDescription: {
|
||||||
|
id: 'app.settings.synced-options.command-history.description',
|
||||||
|
defaultMessage: 'Use the same command history across your instances.',
|
||||||
|
},
|
||||||
|
creativeHotbars: {
|
||||||
|
id: 'app.settings.synced-options.creative-hotbars',
|
||||||
|
defaultMessage: 'Sync saved creative hotbars',
|
||||||
|
},
|
||||||
|
creativeHotbarsDescription: {
|
||||||
|
id: 'app.settings.synced-options.creative-hotbars.description',
|
||||||
|
defaultMessage: 'Use the same saved creative hotbars across your instances.',
|
||||||
|
},
|
||||||
|
gameSettingsSyncSourceTitle: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.game-settings-title',
|
||||||
|
defaultMessage: 'Choose game settings source',
|
||||||
|
},
|
||||||
|
multiplayerServersSyncSourceDescription: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Choose which instance to copy your multiplayer servers from. These servers are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||||
|
},
|
||||||
|
multiplayerServersSyncSourceTitle: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-title',
|
||||||
|
defaultMessage: 'Choose multiplayer servers source',
|
||||||
|
},
|
||||||
|
commandHistorySyncSourceDescription: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.command-history-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Choose which instance to copy your command history from. This history is only used for the initial sync, and you can edit it from any instance afterward.',
|
||||||
|
},
|
||||||
|
commandHistorySyncSourceTitle: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.command-history-title',
|
||||||
|
defaultMessage: 'Choose command history source',
|
||||||
|
},
|
||||||
|
creativeHotbarsSyncSourceDescription: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Choose which instance to copy your saved creative hotbars from. These hotbars are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||||
|
},
|
||||||
|
creativeHotbarsSyncSourceTitle: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-title',
|
||||||
|
defaultMessage: 'Choose creative hotbars source',
|
||||||
|
},
|
||||||
|
gameSettingsSyncSourceDescription: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.game-settings-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Choose which instance to copy your game settings from. These settings are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||||
|
},
|
||||||
|
resourcePacksSyncSourceTitle: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.resource-packs-title',
|
||||||
|
defaultMessage: 'Choose resource packs source',
|
||||||
|
},
|
||||||
|
resourcePacksSyncSourceDescription: {
|
||||||
|
id: 'app.sync-instances-update.choose-source.resource-packs',
|
||||||
|
defaultMessage:
|
||||||
|
'Choose which instance to copy your resource packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||||
|
},
|
||||||
|
dataPacksSyncSourceTitle: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.data-packs-title',
|
||||||
|
defaultMessage: 'Choose data packs source',
|
||||||
|
},
|
||||||
|
dataPacksSyncSourceDescription: {
|
||||||
|
id: 'app.sync-instances-update.choose-source.data-packs',
|
||||||
|
defaultMessage:
|
||||||
|
'Choose which instance to copy your data packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward.',
|
||||||
|
},
|
||||||
|
noServersSyncedYet: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.none-synced-yet',
|
||||||
|
defaultMessage: "You haven't synced any servers yet",
|
||||||
|
},
|
||||||
|
noResourcePacksSyncedYet: {
|
||||||
|
id: 'app.settings.synced-options.resource-packs.none-synced-yet',
|
||||||
|
defaultMessage: "You haven't synced any resource packs yet",
|
||||||
|
},
|
||||||
|
noDataPacksSyncedYet: {
|
||||||
|
id: 'app.settings.synced-options.data-packs.none-synced-yet',
|
||||||
|
defaultMessage: "You haven't synced any data packs yet",
|
||||||
|
},
|
||||||
|
noInstancesToEdit: {
|
||||||
|
id: 'app.settings.synced-options.edit.no-instances',
|
||||||
|
defaultMessage: 'Add an instance before editing synced data.',
|
||||||
|
},
|
||||||
|
noSyncedDataToEdit: {
|
||||||
|
id: 'app.settings.synced-options.edit.no-synced-data',
|
||||||
|
defaultMessage: 'Choose a sync source before editing this setting.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const globalRows: Array<{
|
||||||
|
option: SyncedOption
|
||||||
|
title: keyof typeof messages
|
||||||
|
description?: keyof typeof messages
|
||||||
|
editable?: 'game-settings' | 'servers' | 'commands' | 'resourcepack' | 'datapack'
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
option: 'game_options',
|
||||||
|
title: 'gameSettings',
|
||||||
|
description: 'gameSettingsDescription',
|
||||||
|
editable: 'game-settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
option: 'multiplayer_servers',
|
||||||
|
title: 'multiplayerServers',
|
||||||
|
description: 'multiplayerServersDescription',
|
||||||
|
editable: 'servers',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
option: 'resource_packs',
|
||||||
|
title: 'resourcePacks',
|
||||||
|
description: 'resourcePacksDescription',
|
||||||
|
editable: 'resourcepack',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
option: 'data_packs',
|
||||||
|
title: 'dataPacks',
|
||||||
|
description: 'dataPacksDescription',
|
||||||
|
editable: 'datapack',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
option: 'command_history',
|
||||||
|
title: 'commandHistory',
|
||||||
|
description: 'commandHistoryDescription',
|
||||||
|
editable: 'commands',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
option: 'creative_hotbars',
|
||||||
|
title: 'creativeHotbars',
|
||||||
|
description: 'creativeHotbarsDescription',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const availableGlobalRows = globalRows.filter((row) => isSyncedOptionAvailable(row.option))
|
||||||
|
|
||||||
|
const defaultGlobalOptions: GlobalSyncedOptions = {
|
||||||
|
resource_packs: false,
|
||||||
|
data_packs: false,
|
||||||
|
game_options: false,
|
||||||
|
command_history: false,
|
||||||
|
multiplayer_servers: false,
|
||||||
|
creative_hotbars: false,
|
||||||
|
screenshots: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalOptionsQuery = useQuery(globalSyncedOptionsQueryOptions())
|
||||||
|
const initializedOptionsQuery = useQuery(initializedSyncedOptionsQueryOptions())
|
||||||
|
const gameOptionSourcesQuery = useQuery(gameOptionsSyncSourcesQueryOptions())
|
||||||
|
const resourcePacksQuery = useQuery(syncedPackQueryOptions('resourcepack'))
|
||||||
|
const dataPacksQuery = useQuery(syncedPackQueryOptions('datapack'))
|
||||||
|
const globalOptions = computed(() => globalOptionsQuery.data.value ?? defaultGlobalOptions)
|
||||||
|
const initializedOptions = computed(
|
||||||
|
() => initializedOptionsQuery.data.value ?? defaultGlobalOptions,
|
||||||
|
)
|
||||||
|
const gameOptionSources = computed(() => gameOptionSourcesQuery.data.value ?? [])
|
||||||
|
const instancesQuery = useQuery(instanceListQueryOptions())
|
||||||
|
const instances = computed(() => instancesQuery.data.value ?? [])
|
||||||
|
const serversQuery = useQuery(syncedServersQueryOptions())
|
||||||
|
const syncedServers = computed(() => serversQuery.data.value ?? [])
|
||||||
|
const eligibleGameOptionSourceIds = computed(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
gameOptionSources.value.filter((source) => source.eligible).map((source) => source.source_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const hasGameOptionsToEdit = computed(
|
||||||
|
() =>
|
||||||
|
initializedOptions.value.game_options ||
|
||||||
|
instances.value.some(
|
||||||
|
(instance) =>
|
||||||
|
instance?.synced_options?.game_options &&
|
||||||
|
eligibleGameOptionSourceIds.value.has(instance.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const baseOption = ref<SyncedOption | null>(null)
|
||||||
|
const baseInstanceId = ref('')
|
||||||
|
const baseModal = ref<InstanceType<typeof SyncSourceModal> | null>(null)
|
||||||
|
const baseSourcesLoading = computed(() =>
|
||||||
|
baseOption.value === null
|
||||||
|
? false
|
||||||
|
: baseOption.value === 'game_options'
|
||||||
|
? gameOptionSourcesQuery.isPending.value
|
||||||
|
: instancesQuery.isPending.value,
|
||||||
|
)
|
||||||
|
const baseSourcesError = computed(() =>
|
||||||
|
baseOption.value === 'game_options'
|
||||||
|
? gameOptionSourcesQuery.isError.value && !gameOptionSourcesQuery.data.value
|
||||||
|
: instancesQuery.isError.value && !instancesQuery.data.value,
|
||||||
|
)
|
||||||
|
let baseSourceGeneration = 0
|
||||||
|
|
||||||
|
const baseInstanceTitle = computed(() => {
|
||||||
|
switch (baseOption.value) {
|
||||||
|
case 'game_options':
|
||||||
|
return formatMessage(messages.gameSettingsSyncSourceTitle)
|
||||||
|
case 'multiplayer_servers':
|
||||||
|
return formatMessage(messages.multiplayerServersSyncSourceTitle)
|
||||||
|
case 'command_history':
|
||||||
|
return formatMessage(messages.commandHistorySyncSourceTitle)
|
||||||
|
case 'creative_hotbars':
|
||||||
|
return formatMessage(messages.creativeHotbarsSyncSourceTitle)
|
||||||
|
case 'resource_packs':
|
||||||
|
return formatMessage(messages.resourcePacksSyncSourceTitle)
|
||||||
|
case 'data_packs':
|
||||||
|
return formatMessage(messages.dataPacksSyncSourceTitle)
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseInstanceDescription = computed(() => {
|
||||||
|
switch (baseOption.value) {
|
||||||
|
case 'game_options':
|
||||||
|
return formatMessage(messages.gameSettingsSyncSourceDescription)
|
||||||
|
case 'multiplayer_servers':
|
||||||
|
return formatMessage(messages.multiplayerServersSyncSourceDescription)
|
||||||
|
case 'command_history':
|
||||||
|
return formatMessage(messages.commandHistorySyncSourceDescription)
|
||||||
|
case 'creative_hotbars':
|
||||||
|
return formatMessage(messages.creativeHotbarsSyncSourceDescription)
|
||||||
|
case 'resource_packs':
|
||||||
|
return formatMessage(messages.resourcePacksSyncSourceDescription)
|
||||||
|
case 'data_packs':
|
||||||
|
return formatMessage(messages.dataPacksSyncSourceDescription)
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseInstances = computed(() =>
|
||||||
|
baseOption.value === 'game_options'
|
||||||
|
? gameOptionSources.value.map((source) => ({
|
||||||
|
id: source.source_id,
|
||||||
|
name: source.name,
|
||||||
|
icon_path: source.icon_path,
|
||||||
|
eligible: source.eligible,
|
||||||
|
}))
|
||||||
|
: instances.value.map((instance) => ({
|
||||||
|
id: instance.id,
|
||||||
|
name: instance.name,
|
||||||
|
icon_path: instance.icon_path,
|
||||||
|
eligible:
|
||||||
|
!instance.quarantined &&
|
||||||
|
(baseOption.value !== 'multiplayer_servers' || canSourceMultiplayerServers(instance)),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function invalidateSyncedOptions() {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.gameSources }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['worlds'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySyncedContentMessage(row: (typeof globalRows)[number]) {
|
||||||
|
if (
|
||||||
|
row.editable === 'servers' &&
|
||||||
|
(instances.value.length === 0 || syncedServers.value.length === 0)
|
||||||
|
) {
|
||||||
|
return messages.noServersSyncedYet
|
||||||
|
}
|
||||||
|
if (row.editable === 'resourcepack' && !resourcePacksQuery.data.value?.length) {
|
||||||
|
return messages.noResourcePacksSyncedYet
|
||||||
|
}
|
||||||
|
if (row.editable === 'datapack' && !dataPacksQuery.data.value?.length) {
|
||||||
|
return messages.noDataPacksSyncedYet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEditGlobalOption(row: (typeof globalRows)[number]): boolean {
|
||||||
|
if (!row.editable || !globalOptions.value[row.option]) return false
|
||||||
|
if (emptySyncedContentMessage(row)) return false
|
||||||
|
if (row.editable === 'resourcepack' || row.editable === 'datapack') return true
|
||||||
|
if (row.editable === 'game-settings') return hasGameOptionsToEdit.value
|
||||||
|
return instances.value.length > 0 && initializedOptions.value[row.option]
|
||||||
|
}
|
||||||
|
|
||||||
|
function editGlobalOptionTooltip(row: (typeof globalRows)[number]): string {
|
||||||
|
const emptyMessage = emptySyncedContentMessage(row)
|
||||||
|
if (emptyMessage) return formatMessage(emptyMessage)
|
||||||
|
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
|
||||||
|
return formatMessage(
|
||||||
|
globalOptions.value[row.option] ? commonMessages.editButton : messages.packsDisabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (row.editable === 'game-settings') {
|
||||||
|
if (!hasGameOptionsToEdit.value) return formatMessage(messages.noGameOptionsToEdit)
|
||||||
|
if (!globalOptions.value[row.option]) {
|
||||||
|
return formatMessage(messages.gameSettingsDisabledTooltip)
|
||||||
|
}
|
||||||
|
return formatMessage(messages.gameSettingsButton)
|
||||||
|
}
|
||||||
|
if (instances.value.length === 0) {
|
||||||
|
return formatMessage(messages.noInstancesToEdit)
|
||||||
|
}
|
||||||
|
if (!initializedOptions.value[row.option]) {
|
||||||
|
return formatMessage(messages.noSyncedDataToEdit)
|
||||||
|
}
|
||||||
|
return formatMessage(commonMessages.editButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalOptionMutationVariables = {
|
||||||
|
option: SyncedOption
|
||||||
|
enabled: boolean
|
||||||
|
baseInstanceId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalOptionMutation = useMutation({
|
||||||
|
mutationKey: syncedOptionsKeys.set,
|
||||||
|
mutationFn: ({ option, enabled, baseInstanceId }: GlobalOptionMutationVariables) =>
|
||||||
|
set_global_synced_option(option, enabled, baseInstanceId),
|
||||||
|
onError: handleError,
|
||||||
|
onSuccess: async (options, { option, enabled }) => {
|
||||||
|
queryClient.setQueryData(syncedOptionsKeys.global, options)
|
||||||
|
if (option === 'game_options') {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: gameSettingsKeys.synced })
|
||||||
|
}
|
||||||
|
if (enabled && option === 'multiplayer_servers') {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.servers })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: async (_data, _error, { option }) => {
|
||||||
|
if (queryClient.isMutating({ mutationKey: syncedOptionsKeys.set }) === 1) {
|
||||||
|
if (option === 'resource_packs' || option === 'data_packs') {
|
||||||
|
void Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await invalidateSyncedOptions()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const canToggleGlobalOptions = computed(
|
||||||
|
() =>
|
||||||
|
!!globalOptionsQuery.data.value &&
|
||||||
|
!globalOptionMutation.isPending.value &&
|
||||||
|
!baseSourcesLoading.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
async function chooseBaseInstance(option: SyncedOption) {
|
||||||
|
const generation = ++baseSourceGeneration
|
||||||
|
baseOption.value = option
|
||||||
|
baseInstanceId.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (option === 'game_options') {
|
||||||
|
await queryClient.fetchQuery(gameOptionsSyncSourcesQueryOptions())
|
||||||
|
} else {
|
||||||
|
await queryClient.fetchQuery({ ...instanceListQueryOptions(), staleTime: 0 })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== baseSourceGeneration) return
|
||||||
|
handleError(error)
|
||||||
|
baseModal.value?.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation !== baseSourceGeneration) return
|
||||||
|
const eligibleSources = baseInstances.value.filter((source) => source.eligible)
|
||||||
|
baseInstanceId.value = eligibleSources[0]?.id ?? ''
|
||||||
|
if (eligibleSources.length > 1) {
|
||||||
|
baseModal.value?.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await globalOptionMutation.mutateAsync({
|
||||||
|
option,
|
||||||
|
enabled: true,
|
||||||
|
baseInstanceId: eligibleSources[0]?.id,
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
if (!baseModal.value?.hide()) clearBaseSource()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
|
||||||
|
if (!isSyncedOptionAvailable(option) || !canToggleGlobalOptions.value) return
|
||||||
|
if (enabled && option !== 'screenshots') {
|
||||||
|
void chooseBaseInstance(option)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
globalOptionMutation.mutate({ option, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBaseInstance() {
|
||||||
|
if (!baseOption.value || !baseInstanceId.value) return
|
||||||
|
try {
|
||||||
|
await globalOptionMutation.mutateAsync({
|
||||||
|
option: baseOption.value,
|
||||||
|
enabled: true,
|
||||||
|
baseInstanceId: baseInstanceId.value,
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
baseModal.value?.hide()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editGlobalOption(row: (typeof globalRows)[number]) {
|
||||||
|
if (!isSyncedOptionAvailable(row.option)) return
|
||||||
|
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
|
||||||
|
void syncedPacksModal.value?.show(row.editable)
|
||||||
|
} else if (row.editable === 'game-settings') {
|
||||||
|
openGameSettings()
|
||||||
|
} else if (row.editable === 'commands') {
|
||||||
|
void commandHistoryModal.value?.show()
|
||||||
|
} else {
|
||||||
|
void syncedServersModal.value?.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGameSettings() {
|
||||||
|
if (!hasGameOptionsToEdit.value) return
|
||||||
|
gameSettingsModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGameSettingsSaved() {
|
||||||
|
await invalidateSyncedOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function clearBaseSource() {
|
||||||
|
baseSourceGeneration++
|
||||||
|
baseOption.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(clearBaseSource)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<SyncedPacksModal ref="syncedPacksModal" />
|
||||||
|
<CommandHistoryModal ref="commandHistoryModal" />
|
||||||
|
<SyncedServersModal ref="syncedServersModal" />
|
||||||
|
<GameSettingsModal ref="gameSettingsModal" @saved="handleGameSettingsSaved" />
|
||||||
|
|
||||||
|
<SyncSourceModal
|
||||||
|
ref="baseModal"
|
||||||
|
v-model="baseInstanceId"
|
||||||
|
:title="baseInstanceTitle"
|
||||||
|
:description="baseInstanceDescription"
|
||||||
|
:sources="baseInstances"
|
||||||
|
:loading="baseSourcesLoading"
|
||||||
|
:error="baseSourcesError"
|
||||||
|
:pending="globalOptionMutation.isPending.value"
|
||||||
|
@confirm="confirmBaseInstance"
|
||||||
|
@close="clearBaseSource"
|
||||||
|
@retry="baseOption && chooseBaseInstance(baseOption)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<template #header>
|
||||||
|
<h2
|
||||||
|
id="settings-target-instance-sync-options"
|
||||||
|
tabindex="-1"
|
||||||
|
class="m-0 text-lg font-semibold text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.globalOptionsTitle) }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
<SettingsRow v-for="row in availableGlobalRows" :key="row.option">
|
||||||
|
<template #label>
|
||||||
|
<span :id="`settings-target-instance-sync-${row.option}`" tabindex="-1">
|
||||||
|
{{ formatMessage(messages[row.title]) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="row.description" #description>
|
||||||
|
{{ formatMessage(messages[row.description]) }}
|
||||||
|
</template>
|
||||||
|
<template #control>
|
||||||
|
<span v-if="row.editable" v-tooltip="editGlobalOptionTooltip(row)" class="flex">
|
||||||
|
<IconButton
|
||||||
|
type="outlined"
|
||||||
|
circular
|
||||||
|
:disabled="
|
||||||
|
!canEditGlobalOption(row) ||
|
||||||
|
initializedOptionsQuery.isPending.value ||
|
||||||
|
globalOptionMutation.isPending.value
|
||||||
|
"
|
||||||
|
:label="
|
||||||
|
formatMessage(
|
||||||
|
row.editable === 'game-settings'
|
||||||
|
? messages.gameSettingsButton
|
||||||
|
: commonMessages.editButton,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="editGlobalOption(row)"
|
||||||
|
>
|
||||||
|
<EditIcon aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
<Toggle
|
||||||
|
:id="`global-sync-${row.option}`"
|
||||||
|
:model-value="globalOptions[row.option]"
|
||||||
|
:disabled="!canToggleGlobalOptions"
|
||||||
|
:aria-label="formatMessage(messages[row.title])"
|
||||||
|
@update:model-value="(enabled) => toggleGlobalOption(row.option, enabled)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
|
injectNotificationManager,
|
||||||
NewModal,
|
NewModal,
|
||||||
RadioButtons,
|
RadioButtons,
|
||||||
StyledInput,
|
StyledInput,
|
||||||
@@ -72,6 +73,7 @@ import { edit, list } from '@/helpers/instance'
|
|||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
instanceIds: string[]
|
instanceIds: string[]
|
||||||
@@ -148,7 +150,7 @@ async function deleteGroup(group: string) {
|
|||||||
for (const instance of allInstances.value) {
|
for (const instance of allInstances.value) {
|
||||||
if (instance.groups.includes(group)) {
|
if (instance.groups.includes(group)) {
|
||||||
const newGroups = instance.groups.filter((g) => g !== group)
|
const newGroups = instance.groups.filter((g) => g !== group)
|
||||||
await edit(instance.id, { groups: newGroups }).catch(() => {})
|
await edit(instance.id, { groups: newGroups }).catch(handleError)
|
||||||
instance.groups = newGroups
|
instance.groups = newGroups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +169,7 @@ async function confirm() {
|
|||||||
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
|
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
|
||||||
|
|
||||||
for (const instanceId of props.instanceIds) {
|
for (const instanceId of props.instanceIds) {
|
||||||
await edit(instanceId, { groups }).catch(() => {})
|
await edit(instanceId, { groups }).catch(handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('applied')
|
emit('applied')
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ withDefaults(
|
|||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
count: 1,
|
count: 1,
|
||||||
|
symlinkTarget: null,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ interface IconBackground {
|
|||||||
style: Record<string, string>
|
style: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
function background(
|
function makeBackground(
|
||||||
id: string,
|
id: string,
|
||||||
name: MessageDescriptor,
|
name: MessageDescriptor,
|
||||||
top: string,
|
top: string,
|
||||||
@@ -171,16 +171,16 @@ const backgrounds = [
|
|||||||
backgroundSize: '16px 16px',
|
backgroundSize: '16px 16px',
|
||||||
},
|
},
|
||||||
} satisfies IconBackground,
|
} satisfies IconBackground,
|
||||||
background('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
|
makeBackground('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
|
||||||
background('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
|
makeBackground('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
|
||||||
background('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
|
makeBackground('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
|
||||||
background('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
|
makeBackground('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
|
||||||
background('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
|
makeBackground('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
|
||||||
background('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
|
makeBackground('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
|
||||||
background('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
|
makeBackground('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
|
||||||
background('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
|
makeBackground('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
|
||||||
background('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
|
makeBackground('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
|
||||||
background('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
|
makeBackground('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
|
||||||
]
|
]
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FileArchiveIcon,
|
FileArchiveIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
@@ -20,8 +21,10 @@ import type { PlatformTag } from '@modrinth/utils'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import BackupSettings from '@/components/ui/instance_settings/BackupSettings.vue'
|
||||||
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
|
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
|
||||||
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
|
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
|
||||||
|
import SyncSettings from '@/components/ui/instance_settings/SyncSettings.vue'
|
||||||
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
|
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
|
||||||
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
||||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||||
@@ -52,8 +55,9 @@ let serverMetadataGeneration = 0
|
|||||||
const instanceRef = computed(() => props.instance)
|
const instanceRef = computed(() => props.instance)
|
||||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||||
|
|
||||||
function hide() {
|
function hide(onAfterClose?: () => void) {
|
||||||
tabbedModal.value?.hide()
|
tabbedModal.value?.hide()
|
||||||
|
if (onAfterClose) nextTick(onAfterClose)
|
||||||
}
|
}
|
||||||
|
|
||||||
provideInstanceSettings({
|
provideInstanceSettings({
|
||||||
@@ -105,6 +109,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
|||||||
icon: WrenchIcon,
|
icon: WrenchIcon,
|
||||||
content: InstallationSettings,
|
content: InstallationSettings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: defineMessage({
|
||||||
|
id: 'instance.settings.tabs.backups',
|
||||||
|
defaultMessage: 'Backups',
|
||||||
|
}),
|
||||||
|
icon: FileArchiveIcon,
|
||||||
|
content: BackupSettings,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
|
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
|
||||||
name: defineMessage({
|
name: defineMessage({
|
||||||
@@ -138,6 +150,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
|||||||
icon: CodeIcon,
|
icon: CodeIcon,
|
||||||
content: HooksSettings,
|
content: HooksSettings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: defineMessage({
|
||||||
|
id: 'instance.settings.tabs.sync',
|
||||||
|
defaultMessage: 'Sync',
|
||||||
|
}),
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
content: SyncSettings,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
function getSupportedModpackLoaders() {
|
function getSupportedModpackLoaders() {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const progressWidth = computed(() => `${Math.min(100, (props.current / props.tot
|
|||||||
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
|
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<ButtonStyled circular type="transparent">
|
<ButtonStyled circular type="transparent">
|
||||||
<button :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
|
<button data-onboarding-skip :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ const {
|
|||||||
top: auto !important;
|
top: auto !important;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0 !important;
|
left: var(--left-bar-width, 4rem) !important;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: auto;
|
width: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -252,6 +252,42 @@ export const onboardingMessages = defineMessages({
|
|||||||
id: 'app.onboarding.instance-tabs.description',
|
id: 'app.onboarding.instance-tabs.description',
|
||||||
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
|
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
|
||||||
},
|
},
|
||||||
|
instanceContentTabTitle: {
|
||||||
|
id: 'app.onboarding.instance-content-tab.title',
|
||||||
|
defaultMessage: 'Content',
|
||||||
|
},
|
||||||
|
instanceContentTabDescription: {
|
||||||
|
id: 'app.onboarding.instance-content-tab.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Manage this instance’s mods, resource packs, and shaders here. Search, enable or disable entries, check for updates, and browse for more.',
|
||||||
|
},
|
||||||
|
instanceFilesTabTitle: {
|
||||||
|
id: 'app.onboarding.instance-files-tab.title',
|
||||||
|
defaultMessage: 'Files',
|
||||||
|
},
|
||||||
|
instanceFilesTabDescription: {
|
||||||
|
id: 'app.onboarding.instance-files-tab.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Browse the instance folder and open configs or logs in the built-in editor without leaving the launcher.',
|
||||||
|
},
|
||||||
|
instanceWorldsTabTitle: {
|
||||||
|
id: 'app.onboarding.instance-worlds-tab.title',
|
||||||
|
defaultMessage: 'Worlds',
|
||||||
|
},
|
||||||
|
instanceWorldsTabDescription: {
|
||||||
|
id: 'app.onboarding.instance-worlds-tab.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Manage saved worlds: back them up, rename, edit world settings, and open the world folder.',
|
||||||
|
},
|
||||||
|
instanceLogsTabTitle: {
|
||||||
|
id: 'app.onboarding.instance-logs-tab.title',
|
||||||
|
defaultMessage: 'Logs',
|
||||||
|
},
|
||||||
|
instanceLogsTabDescription: {
|
||||||
|
id: 'app.onboarding.instance-logs-tab.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Read the instance’s live logs to trace crashes and errors without digging through files.',
|
||||||
|
},
|
||||||
labTitle: {
|
labTitle: {
|
||||||
id: 'app.onboarding.lab.title',
|
id: 'app.onboarding.lab.title',
|
||||||
defaultMessage: 'Useful tools, built in',
|
defaultMessage: 'Useful tools, built in',
|
||||||
@@ -276,6 +312,48 @@ export const onboardingMessages = defineMessages({
|
|||||||
},
|
},
|
||||||
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
|
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
|
||||||
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
|
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
|
||||||
|
homeIntroTitle: { id: 'app.onboarding.home-intro.title', defaultMessage: 'Welcome home' },
|
||||||
|
homeIntroDescription: {
|
||||||
|
id: 'app.onboarding.home-intro.description',
|
||||||
|
defaultMessage:
|
||||||
|
'This is your home page. It shows your instances, recent activity, and quick shortcuts to get playing.',
|
||||||
|
},
|
||||||
|
accountLoginTitle: {
|
||||||
|
id: 'app.onboarding.account-login.title',
|
||||||
|
defaultMessage: 'Sign in to StarLight',
|
||||||
|
},
|
||||||
|
accountLoginDescription: {
|
||||||
|
id: 'app.onboarding.account-login.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Sign in to the StarLight skin site here to use your StarLight players, install hosted modpacks, and sync your account.',
|
||||||
|
},
|
||||||
|
announcementTitle: {
|
||||||
|
id: 'app.onboarding.announcement.title',
|
||||||
|
defaultMessage: 'Latest announcements',
|
||||||
|
},
|
||||||
|
announcementDescription: {
|
||||||
|
id: 'app.onboarding.announcement.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Open the announcement entry in the sidebar to read the latest news, rules, and server notices.',
|
||||||
|
},
|
||||||
|
createStarlightTitle: {
|
||||||
|
id: 'app.onboarding.create-starlight.title',
|
||||||
|
defaultMessage: 'Create a StarLight instance',
|
||||||
|
},
|
||||||
|
createStarlightDescription: {
|
||||||
|
id: 'app.onboarding.create-starlight.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Use Create new instance here. A StarLight instance installs the server-selected modpack and versions, then keeps them updated before each launch.',
|
||||||
|
},
|
||||||
|
settingsUpdatesTitle: {
|
||||||
|
id: 'app.onboarding.settings-updates.title',
|
||||||
|
defaultMessage: 'Stay up to date',
|
||||||
|
},
|
||||||
|
settingsUpdatesDescription: {
|
||||||
|
id: 'app.onboarding.settings-updates.description',
|
||||||
|
defaultMessage:
|
||||||
|
'The Updates tab controls the update channel, checks for new launcher versions, and shows the changelog.',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const step = (
|
const step = (
|
||||||
@@ -331,20 +409,33 @@ const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescri
|
|||||||
|
|
||||||
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||||
main: [
|
main: [
|
||||||
step(
|
inspect(
|
||||||
'welcome',
|
'home-intro',
|
||||||
'manual',
|
'home-widget-grid',
|
||||||
copy(
|
onboardingMessages.homeIntroTitle,
|
||||||
onboardingMessages.welcomeTitle,
|
onboardingMessages.homeIntroDescription,
|
||||||
onboardingMessages.welcomeDescription,
|
|
||||||
onboardingMessages.start,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
inspect(
|
inspect(
|
||||||
'home-layout-switch',
|
'account-login',
|
||||||
'home-layout-switch',
|
'sidebar-accounts',
|
||||||
onboardingMessages.homeLayoutTitle,
|
onboardingMessages.accountLoginTitle,
|
||||||
onboardingMessages.homeLayoutDescription,
|
onboardingMessages.accountLoginDescription,
|
||||||
|
),
|
||||||
|
inspect(
|
||||||
|
'announcement',
|
||||||
|
'home-announcement',
|
||||||
|
onboardingMessages.announcementTitle,
|
||||||
|
onboardingMessages.announcementDescription,
|
||||||
|
),
|
||||||
|
step(
|
||||||
|
'create-starlight',
|
||||||
|
'navigate',
|
||||||
|
copy(
|
||||||
|
onboardingMessages.createStarlightTitle,
|
||||||
|
onboardingMessages.createStarlightDescription,
|
||||||
|
onboardingMessages.clickCreate,
|
||||||
|
),
|
||||||
|
control('create-instance', '/create'),
|
||||||
),
|
),
|
||||||
step(
|
step(
|
||||||
'discover-navigation',
|
'discover-navigation',
|
||||||
@@ -354,7 +445,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
onboardingMessages.discoverDescription,
|
onboardingMessages.discoverDescription,
|
||||||
onboardingMessages.clickDiscover,
|
onboardingMessages.clickDiscover,
|
||||||
),
|
),
|
||||||
control('nav-discover', '/browse/modpack'),
|
control('nav-discover', '/browse'),
|
||||||
),
|
),
|
||||||
inspect(
|
inspect(
|
||||||
'discover-content',
|
'discover-content',
|
||||||
@@ -362,22 +453,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
onboardingMessages.browseTitle,
|
onboardingMessages.browseTitle,
|
||||||
onboardingMessages.browseDescription,
|
onboardingMessages.browseDescription,
|
||||||
),
|
),
|
||||||
step(
|
|
||||||
'discover-favorites-navigation',
|
|
||||||
'navigate',
|
|
||||||
copy(
|
|
||||||
onboardingMessages.favoritesTitle,
|
|
||||||
onboardingMessages.favoritesDescription,
|
|
||||||
onboardingMessages.clickFavorites,
|
|
||||||
),
|
|
||||||
control('browse-favorites-tab', '/browse/favorites'),
|
|
||||||
),
|
|
||||||
inspect(
|
|
||||||
'discover-favorites-content',
|
|
||||||
'browse-favorites-content',
|
|
||||||
onboardingMessages.favoritesTitle,
|
|
||||||
onboardingMessages.favoritesDescription,
|
|
||||||
),
|
|
||||||
step(
|
step(
|
||||||
'skins-navigation',
|
'skins-navigation',
|
||||||
'navigate',
|
'navigate',
|
||||||
@@ -394,6 +469,22 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
onboardingMessages.skinsPageTitle,
|
onboardingMessages.skinsPageTitle,
|
||||||
onboardingMessages.skinsPageDescription,
|
onboardingMessages.skinsPageDescription,
|
||||||
),
|
),
|
||||||
|
step(
|
||||||
|
'library-navigation',
|
||||||
|
'navigate',
|
||||||
|
copy(
|
||||||
|
onboardingMessages.libraryTitle,
|
||||||
|
onboardingMessages.libraryDescription,
|
||||||
|
onboardingMessages.clickLibrary,
|
||||||
|
),
|
||||||
|
control('nav-library', '/library'),
|
||||||
|
),
|
||||||
|
inspect(
|
||||||
|
'library-content',
|
||||||
|
'library-content',
|
||||||
|
onboardingMessages.libraryPageTitle,
|
||||||
|
onboardingMessages.libraryPageDescription,
|
||||||
|
),
|
||||||
step(
|
step(
|
||||||
'lab-navigation',
|
'lab-navigation',
|
||||||
'navigate',
|
'navigate',
|
||||||
@@ -428,7 +519,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
),
|
),
|
||||||
step(
|
step(
|
||||||
'settings-navigation',
|
'settings-navigation',
|
||||||
'activate',
|
'navigate',
|
||||||
copy(
|
copy(
|
||||||
onboardingMessages.settingsTitle,
|
onboardingMessages.settingsTitle,
|
||||||
onboardingMessages.settingsDescription,
|
onboardingMessages.settingsDescription,
|
||||||
@@ -436,95 +527,15 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
),
|
),
|
||||||
control('nav-settings', '/settings'),
|
control('nav-settings', '/settings'),
|
||||||
),
|
),
|
||||||
...settingsTourSteps.map(([id, targetId, title, description], index) =>
|
|
||||||
step(id, 'activate', copy(title, description, onboardingMessages.clickTab), {
|
|
||||||
...control(targetId),
|
|
||||||
closeSettingsAfter: index === settingsTourSteps.length - 1,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
step(
|
step(
|
||||||
'library-navigation',
|
'settings-updates',
|
||||||
'navigate',
|
|
||||||
copy(
|
|
||||||
onboardingMessages.libraryTitle,
|
|
||||||
onboardingMessages.libraryDescription,
|
|
||||||
onboardingMessages.clickLibrary,
|
|
||||||
),
|
|
||||||
control('nav-library', '/library'),
|
|
||||||
),
|
|
||||||
inspect(
|
|
||||||
'library-content',
|
|
||||||
'library-content',
|
|
||||||
onboardingMessages.libraryPageTitle,
|
|
||||||
onboardingMessages.libraryPageDescription,
|
|
||||||
),
|
|
||||||
step(
|
|
||||||
'create-instance',
|
|
||||||
'navigate',
|
|
||||||
copy(
|
|
||||||
onboardingMessages.createTitle,
|
|
||||||
onboardingMessages.createDescription,
|
|
||||||
onboardingMessages.clickCreate,
|
|
||||||
),
|
|
||||||
control('create-instance', '/create'),
|
|
||||||
),
|
|
||||||
inspect(
|
|
||||||
'creation-instance-mode',
|
|
||||||
'creation-instance-mode',
|
|
||||||
onboardingMessages.instanceModeTitle,
|
|
||||||
onboardingMessages.instanceModeDescription,
|
|
||||||
),
|
|
||||||
step(
|
|
||||||
'creation-flow',
|
|
||||||
'activate',
|
'activate',
|
||||||
copy(
|
copy(
|
||||||
onboardingMessages.creationTitle,
|
onboardingMessages.settingsUpdatesTitle,
|
||||||
onboardingMessages.creationDescription,
|
onboardingMessages.settingsUpdatesDescription,
|
||||||
onboardingMessages.clickCreationMethod,
|
onboardingMessages.clickTab,
|
||||||
),
|
),
|
||||||
{
|
control('settings-tab-updates'),
|
||||||
targetId: 'creation-methods',
|
|
||||||
branchByTarget: {
|
|
||||||
'creation-method-starlight': { next: 'complete' },
|
|
||||||
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
|
|
||||||
'creation-method-import': { next: 'complete' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
inspect(
|
|
||||||
'creation-name',
|
|
||||||
'creation-name',
|
|
||||||
onboardingMessages.creationNameTitle,
|
|
||||||
onboardingMessages.creationNameDescription,
|
|
||||||
),
|
|
||||||
inspect(
|
|
||||||
'creation-loader',
|
|
||||||
'creation-loader',
|
|
||||||
onboardingMessages.creationLoaderTitle,
|
|
||||||
onboardingMessages.creationLoaderDescription,
|
|
||||||
),
|
|
||||||
step(
|
|
||||||
'creation-version',
|
|
||||||
'inspect',
|
|
||||||
copy(
|
|
||||||
onboardingMessages.creationVersionTitle,
|
|
||||||
onboardingMessages.creationVersionDescription,
|
|
||||||
onboardingMessages.continueArea,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
targetId: 'creation-game-version',
|
|
||||||
nextByCreationPath: { custom: 'creation-confirm' },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
step(
|
|
||||||
'creation-confirm',
|
|
||||||
'inspect',
|
|
||||||
copy(
|
|
||||||
onboardingMessages.creationConfirmTitle,
|
|
||||||
onboardingMessages.creationConfirmDescription,
|
|
||||||
onboardingMessages.finishArea,
|
|
||||||
),
|
|
||||||
{ targetId: 'creation-confirm' },
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
instance: [
|
instance: [
|
||||||
@@ -534,15 +545,29 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
|||||||
onboardingMessages.instanceActionsTitle,
|
onboardingMessages.instanceActionsTitle,
|
||||||
onboardingMessages.instanceActionsDescription,
|
onboardingMessages.instanceActionsDescription,
|
||||||
),
|
),
|
||||||
step(
|
inspect(
|
||||||
'instance-tabs',
|
'instance-content-tab',
|
||||||
'inspect',
|
'instance-tab-content',
|
||||||
copy(
|
onboardingMessages.instanceContentTabTitle,
|
||||||
onboardingMessages.instanceTabsTitle,
|
onboardingMessages.instanceContentTabDescription,
|
||||||
onboardingMessages.instanceTabsDescription,
|
),
|
||||||
onboardingMessages.finishArea,
|
inspect(
|
||||||
),
|
'instance-files-tab',
|
||||||
{ targetId: 'instance-tabs' },
|
'instance-tab-files',
|
||||||
|
onboardingMessages.instanceFilesTabTitle,
|
||||||
|
onboardingMessages.instanceFilesTabDescription,
|
||||||
|
),
|
||||||
|
inspect(
|
||||||
|
'instance-worlds-tab',
|
||||||
|
'instance-tab-worlds',
|
||||||
|
onboardingMessages.instanceWorldsTabTitle,
|
||||||
|
onboardingMessages.instanceWorldsTabDescription,
|
||||||
|
),
|
||||||
|
inspect(
|
||||||
|
'instance-logs-tab',
|
||||||
|
'instance-tab-logs',
|
||||||
|
onboardingMessages.instanceLogsTabTitle,
|
||||||
|
onboardingMessages.instanceLogsTabDescription,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,14 @@ export function useOnboardingTour(
|
|||||||
|
|
||||||
function scheduleMissingTargetRetry(stepId: string) {
|
function scheduleMissingTargetRetry(stepId: string) {
|
||||||
if (targetRetryCount >= missingTargetRetryLimit) {
|
if (targetRetryCount >= missingTargetRetryLimit) {
|
||||||
|
// 超过重试上限:inspect 步(介绍性步骤)不自动跳过,
|
||||||
|
// 保持无锚点状态,让气泡停靠底部等用户手动继续。
|
||||||
|
if (step.value.interaction === 'inspect') {
|
||||||
|
targetElement.value = undefined
|
||||||
|
targetRect.value = null
|
||||||
|
clearModalReservation()
|
||||||
|
return
|
||||||
|
}
|
||||||
void advance()
|
void advance()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -271,8 +279,11 @@ export function useOnboardingTour(
|
|||||||
function handleDocumentClick(event: MouseEvent) {
|
function handleDocumentClick(event: MouseEvent) {
|
||||||
if (!visible.value) return
|
if (!visible.value) return
|
||||||
const clickedElement = event.target instanceof Element ? event.target : null
|
const clickedElement = event.target instanceof Element ? event.target : null
|
||||||
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
|
|
||||||
|
|
||||||
|
// 跳过/关闭按钮由其自身处理,不在全局点击里推进
|
||||||
|
if (clickedElement?.closest('[data-onboarding-skip]')) return
|
||||||
|
|
||||||
|
// inspect 步:点击任意位置(含导览气泡本身)都继续下一步
|
||||||
if (step.value.interaction === 'inspect') {
|
if (step.value.interaction === 'inspect') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
@@ -280,11 +291,14 @@ export function useOnboardingTour(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 非 inspect 步:点在导览 UI 自身时不处理
|
||||||
|
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
|
||||||
|
|
||||||
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
|
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
|
||||||
if (clickedElement && handleBranchClick(clickedElement)) return
|
if (clickedElement && handleBranchClick(clickedElement)) return
|
||||||
if (!targetElement.value?.contains(event.target as Node)) return
|
if (!targetElement.value?.contains(event.target as Node)) return
|
||||||
|
|
||||||
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
|
if (step.value.expectedPath && !route.path.startsWith(step.value.expectedPath)) {
|
||||||
waitingForRoute.value = true
|
waitingForRoute.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -301,7 +315,7 @@ export function useOnboardingTour(
|
|||||||
() => route.path,
|
() => route.path,
|
||||||
(path) => {
|
(path) => {
|
||||||
if (!waitingForRoute.value || !step.value.expectedPath) return
|
if (!waitingForRoute.value || !step.value.expectedPath) return
|
||||||
if (path !== step.value.expectedPath) return
|
if (!path.startsWith(step.value.expectedPath)) return
|
||||||
waitingForRoute.value = false
|
waitingForRoute.value = false
|
||||||
void advance()
|
void advance()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
<script lang="ts"></script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
|
||||||
|
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
defineMessages,
|
||||||
|
IconButton,
|
||||||
|
useDebugLogger,
|
||||||
|
useFormatDateTime,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
|
||||||
|
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
screenshot: InstanceScreenshot
|
||||||
|
selectionKey: string
|
||||||
|
selected: boolean
|
||||||
|
selectionActive: boolean
|
||||||
|
activeDragged: boolean
|
||||||
|
canDrag: boolean
|
||||||
|
showInstanceName: boolean
|
||||||
|
highlighted: boolean
|
||||||
|
copied: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'activate', event: MouseEvent | KeyboardEvent): void
|
||||||
|
(e: 'copy' | 'edit' | 'toggle-selection'): void
|
||||||
|
(e: 'more', event: MouseEvent): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const card = ref<HTMLElement>()
|
||||||
|
const thumbnail = useImageThumbnail(
|
||||||
|
() => ({
|
||||||
|
instanceId: props.screenshot.instance_id,
|
||||||
|
filePath: `screenshots/${props.screenshot.file_name}`,
|
||||||
|
path: props.screenshot.path,
|
||||||
|
}),
|
||||||
|
512,
|
||||||
|
() => props.screenshot.modified_at,
|
||||||
|
)
|
||||||
|
const image = ref<HTMLImageElement>()
|
||||||
|
const imageReady = ref(false)
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const debugImage = useDebugLogger('Screenshots:Card')
|
||||||
|
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
let loadStartedAt = performance.now()
|
||||||
|
let loadGeneration = 0
|
||||||
|
const messages = defineMessages({
|
||||||
|
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
|
||||||
|
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
|
||||||
|
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
|
||||||
|
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
|
||||||
|
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
|
||||||
|
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const sensors = [
|
||||||
|
PointerSensor.configure({
|
||||||
|
preventActivation: () => false,
|
||||||
|
}),
|
||||||
|
KeyboardSensor,
|
||||||
|
]
|
||||||
|
|
||||||
|
useDraggable({
|
||||||
|
id: computed(() => `screenshot:${props.selectionKey}`),
|
||||||
|
element: card,
|
||||||
|
disabled: computed(() => !props.canDrag),
|
||||||
|
sensors,
|
||||||
|
data: computed(() => ({
|
||||||
|
selectionKey: props.selectionKey,
|
||||||
|
instanceId: props.screenshot.instance_id,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
function activate(event: MouseEvent | KeyboardEvent) {
|
||||||
|
if (event instanceof KeyboardEvent) {
|
||||||
|
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
emit('activate', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markImageLoaded() {
|
||||||
|
const loadedImage = image.value
|
||||||
|
const loadedUrl = thumbnail.value
|
||||||
|
const generation = loadGeneration
|
||||||
|
if (!loadedImage) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadedImage.decode()
|
||||||
|
} catch {
|
||||||
|
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
generation !== loadGeneration ||
|
||||||
|
image.value !== loadedImage ||
|
||||||
|
thumbnail.value !== loadedUrl
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasReady = imageReady.value
|
||||||
|
imageReady.value = true
|
||||||
|
if (!wasReady) {
|
||||||
|
debugImage('image loaded', {
|
||||||
|
id: props.screenshot.id,
|
||||||
|
fileName: props.screenshot.file_name,
|
||||||
|
url: props.screenshot.url,
|
||||||
|
loadDurationMs: performance.now() - loadStartedAt,
|
||||||
|
naturalWidth: image.value?.naturalWidth,
|
||||||
|
naturalHeight: image.value?.naturalHeight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markImageFailed(event: Event) {
|
||||||
|
debugImage('image failed', {
|
||||||
|
id: props.screenshot.id,
|
||||||
|
fileName: props.screenshot.file_name,
|
||||||
|
url: props.screenshot.url,
|
||||||
|
loadDurationMs: performance.now() - loadStartedAt,
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
debugImage('mounted', {
|
||||||
|
id: props.screenshot.id,
|
||||||
|
fileName: props.screenshot.file_name,
|
||||||
|
url: props.screenshot.url,
|
||||||
|
complete: image.value?.complete,
|
||||||
|
})
|
||||||
|
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
loadGeneration += 1
|
||||||
|
debugImage('unmounted', {
|
||||||
|
id: props.screenshot.id,
|
||||||
|
fileName: props.screenshot.file_name,
|
||||||
|
url: props.screenshot.url,
|
||||||
|
loaded: imageReady.value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(thumbnail, (url, previousUrl) => {
|
||||||
|
loadGeneration += 1
|
||||||
|
loadStartedAt = performance.now()
|
||||||
|
imageReady.value = false
|
||||||
|
debugImage('source changed', {
|
||||||
|
id: props.screenshot.id,
|
||||||
|
fileName: props.screenshot.file_name,
|
||||||
|
previousUrl,
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
ref="card"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
|
||||||
|
:class="{
|
||||||
|
'!border-brand brightness-110': selected,
|
||||||
|
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
|
||||||
|
'opacity-50': activeDragged,
|
||||||
|
'cursor-grab active:cursor-grabbing': canDrag,
|
||||||
|
}"
|
||||||
|
data-screenshot-card
|
||||||
|
:data-screenshot-id="screenshot.id"
|
||||||
|
:data-selection-key="selectionKey"
|
||||||
|
:aria-label="
|
||||||
|
selectionActive
|
||||||
|
? formatMessage(selected ? messages.deselect : messages.select, {
|
||||||
|
name: screenshot.file_name,
|
||||||
|
})
|
||||||
|
: screenshot.file_name
|
||||||
|
"
|
||||||
|
:aria-pressed="selectionActive ? selected : undefined"
|
||||||
|
@click="activate"
|
||||||
|
@contextmenu.prevent.stop="emit('more', $event)"
|
||||||
|
@keydown="activate"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
|
||||||
|
:aria-label="
|
||||||
|
formatMessage(selected ? messages.deselect : messages.select, {
|
||||||
|
name: screenshot.file_name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:aria-pressed="selected"
|
||||||
|
@click.stop="emit('toggle-selection')"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
|
||||||
|
:class="
|
||||||
|
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span v-if="selected" class="absolute inset-0 rounded-full bg-brand" />
|
||||||
|
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="thumbnail"
|
||||||
|
ref="image"
|
||||||
|
:src="thumbnail"
|
||||||
|
:alt="screenshot.file_name"
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
|
||||||
|
:class="imageReady ? 'opacity-100' : 'opacity-0'"
|
||||||
|
@load="markImageLoaded"
|
||||||
|
@error="markImageFailed"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute inset-0 bg-surface-4"
|
||||||
|
:class="{ 'animate-pulse': !imageReady }"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-[var(--color-text-primary)] opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
|
||||||
|
{{ screenshot.file_name }}
|
||||||
|
</div>
|
||||||
|
<div class="truncate text-xs text-[var(--color-text-tertiary)]">
|
||||||
|
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!selectionActive"
|
||||||
|
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="formatMessage(messages.edit)"
|
||||||
|
:label="formatMessage(messages.edit)"
|
||||||
|
type="quiet"
|
||||||
|
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
|
||||||
|
@click="emit('edit')"
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
|
||||||
|
:label="formatMessage(copied ? messages.copied : messages.copy)"
|
||||||
|
type="quiet"
|
||||||
|
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
|
||||||
|
@click="emit('copy')"
|
||||||
|
>
|
||||||
|
<CheckIcon v-if="copied" class="text-green" />
|
||||||
|
<ClipboardCopyIcon v-else />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
v-tooltip="formatMessage(messages.moreActions)"
|
||||||
|
:label="formatMessage(messages.moreActions)"
|
||||||
|
type="quiet"
|
||||||
|
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
|
||||||
|
@click="emit('more', $event)"
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.screenshot-card-fade {
|
||||||
|
transition: opacity 350ms ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import ScreenshotDragPreview from './drag-preview.vue'
|
||||||
|
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: ScreenshotDragGatherItem[]
|
||||||
|
target: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
complete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const gathered = ref(false)
|
||||||
|
const finishing = ref(false)
|
||||||
|
let animationFrame: number | undefined
|
||||||
|
let completionTimer: number | undefined
|
||||||
|
|
||||||
|
const itemContainerStyles = computed(() =>
|
||||||
|
props.items.map((_, index) => ({
|
||||||
|
left: `${props.target.x}px`,
|
||||||
|
top: `${props.target.y}px`,
|
||||||
|
zIndex: `${index}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const itemStyles = computed(() =>
|
||||||
|
props.items.map((item, index) => {
|
||||||
|
const originX = item.rect.left - props.target.x
|
||||||
|
const originY = item.rect.top - props.target.y
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: `${item.rect.width}px`,
|
||||||
|
height: `${item.rect.height}px`,
|
||||||
|
transform: gathered.value
|
||||||
|
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
|
||||||
|
: `translate3d(${originX}px, ${originY}px, 0)`,
|
||||||
|
transitionDuration: `${gatherDuration}ms`,
|
||||||
|
transitionDelay: `${Math.min(index, 8) * 10}ms`,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
animationFrame = requestAnimationFrame(() => {
|
||||||
|
animationFrame = requestAnimationFrame(() => {
|
||||||
|
gathered.value = true
|
||||||
|
completionTimer = window.setTimeout(
|
||||||
|
() => {
|
||||||
|
finishing.value = true
|
||||||
|
emit('complete')
|
||||||
|
},
|
||||||
|
Math.max(gatherDuration - 250, 0),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (animationFrame !== undefined) {
|
||||||
|
cancelAnimationFrame(animationFrame)
|
||||||
|
}
|
||||||
|
if (completionTimer !== undefined) {
|
||||||
|
clearTimeout(completionTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
|
||||||
|
:class="finishing ? 'opacity-0' : 'opacity-100'"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="item.selectionKey"
|
||||||
|
class="fixed"
|
||||||
|
:style="itemContainerStyles[index]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
|
||||||
|
:style="itemStyles[index]"
|
||||||
|
>
|
||||||
|
<ScreenshotDragPreview :screenshot="item.screenshot" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TagItem } from '@modrinth/ui'
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
|
||||||
|
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||||
|
|
||||||
|
import { gatherDuration } from './use-screenshot-drag-gather'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
screenshot: InstanceScreenshot
|
||||||
|
count?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const thumbnail = useImageThumbnail(
|
||||||
|
() => ({
|
||||||
|
instanceId: props.screenshot.instance_id,
|
||||||
|
filePath: `screenshots/${props.screenshot.file_name}`,
|
||||||
|
path: props.screenshot.path,
|
||||||
|
}),
|
||||||
|
512,
|
||||||
|
() => props.screenshot.modified_at,
|
||||||
|
)
|
||||||
|
const showGatheredCount = ref(false)
|
||||||
|
let countTimer: number | undefined
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
showGatheredCount.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
countTimer = window.setTimeout(() => {
|
||||||
|
showGatheredCount.value = true
|
||||||
|
countTimer = undefined
|
||||||
|
}, gatherDuration - 150)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (countTimer !== undefined) {
|
||||||
|
clearTimeout(countTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div aria-hidden="true" class="relative w-full select-none">
|
||||||
|
<div
|
||||||
|
v-if="count > 1"
|
||||||
|
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
|
||||||
|
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="count > 1"
|
||||||
|
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
|
||||||
|
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
|
||||||
|
>
|
||||||
|
<img v-if="thumbnail" :src="thumbnail" alt="" class="h-full w-full object-cover" />
|
||||||
|
<TagItem
|
||||||
|
v-if="count > 1"
|
||||||
|
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-[var(--color-text-tertiary)] motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
|
||||||
|
>
|
||||||
|
{{ count }}
|
||||||
|
</TagItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useDroppable } from '@dnd-kit/vue'
|
||||||
|
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||||
|
|
||||||
|
import ScreenshotCard from './card.vue'
|
||||||
|
import ScreenshotSection from './section.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
screenshots: InstanceScreenshot[]
|
||||||
|
renderedScreenshots?: InstanceScreenshot[]
|
||||||
|
virtualGridHeight?: number
|
||||||
|
virtualGridTop?: number
|
||||||
|
selectedKeys: ReadonlySet<string>
|
||||||
|
selectionActive: boolean
|
||||||
|
activeDraggedKeys: ReadonlySet<string>
|
||||||
|
showDropOutline: boolean
|
||||||
|
canDrag: boolean
|
||||||
|
dropInstanceId?: string
|
||||||
|
dropCustomGroup?: boolean
|
||||||
|
dropCustomGroupId?: string
|
||||||
|
showInstanceName: boolean
|
||||||
|
highlightedScreenshotId?: string
|
||||||
|
copiedScreenshotIds: ReadonlySet<string>
|
||||||
|
forceOpen: boolean
|
||||||
|
hideHeader?: boolean
|
||||||
|
editableTitle?: boolean
|
||||||
|
startEditingTitle?: boolean
|
||||||
|
maxTitleLength?: number
|
||||||
|
validateTitle?: (value: string) => boolean
|
||||||
|
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const collapsed = defineModel<boolean>('collapsed', { required: true })
|
||||||
|
const dropTarget = ref<HTMLElement>()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const debugLayout = useDebugLogger('Screenshots:Group')
|
||||||
|
const messages = defineMessages({
|
||||||
|
emptyGroup: {
|
||||||
|
id: 'app.screenshots.group.empty',
|
||||||
|
defaultMessage: 'Drag and drop to add screenshots.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const shouldShowGrid = computed(() =>
|
||||||
|
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
|
||||||
|
)
|
||||||
|
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
|
||||||
|
const renderGrid = ref(shouldShowGrid.value)
|
||||||
|
const virtualGridStyle = computed(() =>
|
||||||
|
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
|
||||||
|
)
|
||||||
|
const visibleGridStyle = computed(() =>
|
||||||
|
props.virtualGridTop === undefined
|
||||||
|
? undefined
|
||||||
|
: { transform: `translateY(${props.virtualGridTop}px)` },
|
||||||
|
)
|
||||||
|
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let resizeObserver: ResizeObserver | undefined
|
||||||
|
|
||||||
|
async function logGeometry(reason: string) {
|
||||||
|
await nextTick()
|
||||||
|
const rect = dropTarget.value?.getBoundingClientRect()
|
||||||
|
debugLayout(reason, {
|
||||||
|
id: props.id,
|
||||||
|
title: props.title,
|
||||||
|
collapsed: collapsed.value,
|
||||||
|
shouldShowGrid: shouldShowGrid.value,
|
||||||
|
renderGrid: renderGrid.value,
|
||||||
|
screenshotCount: props.screenshots.length,
|
||||||
|
renderedCount: visibleScreenshots.value.length,
|
||||||
|
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
|
||||||
|
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
|
||||||
|
virtualGridHeight: props.virtualGridHeight,
|
||||||
|
virtualGridTop: props.virtualGridTop,
|
||||||
|
actual: rect
|
||||||
|
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.renderedScreenshots ?? props.screenshots,
|
||||||
|
(screenshots) => {
|
||||||
|
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
|
||||||
|
void logGeometry('rendered screenshots changed')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.virtualGridHeight, props.virtualGridTop] as const,
|
||||||
|
() => void logGeometry('virtual grid geometry changed'),
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
shouldShowGrid,
|
||||||
|
(showGrid, previouslyShown) => {
|
||||||
|
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||||
|
if (showGrid) {
|
||||||
|
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
|
||||||
|
renderGrid.value = true
|
||||||
|
void logGeometry('grid shown')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!previouslyShown) {
|
||||||
|
renderGrid.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unmountGridTimeout = setTimeout(() => {
|
||||||
|
renderGrid.value = false
|
||||||
|
void logGeometry('grid unmounted after collapse')
|
||||||
|
unmountGridTimeout = undefined
|
||||||
|
}, 300)
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
debugLayout('mounted', { id: props.id, title: props.title })
|
||||||
|
void logGeometry('mounted geometry')
|
||||||
|
if (dropTarget.value) {
|
||||||
|
resizeObserver = new ResizeObserver(([entry]) => {
|
||||||
|
const rect = entry?.target.getBoundingClientRect()
|
||||||
|
debugLayout('actual size changed', {
|
||||||
|
id: props.id,
|
||||||
|
title: props.title,
|
||||||
|
width: rect?.width,
|
||||||
|
height: rect?.height,
|
||||||
|
expectedHeight:
|
||||||
|
(props.hideHeader ? 0 : 40) +
|
||||||
|
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
|
||||||
|
12,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
resizeObserver.observe(dropTarget.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
debugLayout('unmounted', {
|
||||||
|
id: props.id,
|
||||||
|
title: props.title,
|
||||||
|
renderedCount: visibleScreenshots.value.length,
|
||||||
|
})
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
|
||||||
|
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
|
||||||
|
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
useDroppable({
|
||||||
|
id: computed(() => `screenshot-group:${props.id}`),
|
||||||
|
element: dropTarget,
|
||||||
|
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
|
||||||
|
data: computed(() =>
|
||||||
|
props.dropCustomGroup
|
||||||
|
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
|
||||||
|
: { groupId: props.id, instanceId: props.dropInstanceId },
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||||
|
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="dropTarget"
|
||||||
|
class="group/instance-container relative select-none pb-3 transition-colors"
|
||||||
|
>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-opacity duration-150 ease-out"
|
||||||
|
enter-from-class="!opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition-opacity duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="!opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showDropOutline"
|
||||||
|
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-brand bg-brand-highlight opacity-40"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
<ScreenshotSection
|
||||||
|
v-model:collapsed="collapsed"
|
||||||
|
:title="title"
|
||||||
|
:count="screenshots.length"
|
||||||
|
:force-open="forceOpen"
|
||||||
|
:hide-header="hideHeader"
|
||||||
|
:editable="editableTitle"
|
||||||
|
:start-editing="startEditingTitle"
|
||||||
|
:max-title-length="maxTitleLength"
|
||||||
|
:validate-title="validateTitle"
|
||||||
|
:on-title-change="onTitleChange"
|
||||||
|
>
|
||||||
|
<template #actions="{ startEditing }">
|
||||||
|
<slot name="actions" :start-editing="startEditing" />
|
||||||
|
</template>
|
||||||
|
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
|
||||||
|
<div
|
||||||
|
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
|
||||||
|
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
|
||||||
|
:style="visibleGridStyle"
|
||||||
|
>
|
||||||
|
<ScreenshotCard
|
||||||
|
v-for="screenshot in visibleScreenshots"
|
||||||
|
:key="getSelectionKey(screenshot)"
|
||||||
|
:screenshot="screenshot"
|
||||||
|
:selection-key="getSelectionKey(screenshot)"
|
||||||
|
:selected="selectedKeys.has(getSelectionKey(screenshot))"
|
||||||
|
:selection-active="selectionActive"
|
||||||
|
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
|
||||||
|
:can-drag="canDrag"
|
||||||
|
:show-instance-name="showInstanceName"
|
||||||
|
:highlighted="highlightedScreenshotId === screenshot.id"
|
||||||
|
:copied="copiedScreenshotIds.has(screenshot.id)"
|
||||||
|
@activate="(event) => emit('activate', screenshot, event)"
|
||||||
|
@toggle-selection="emit('toggle-selection', screenshot)"
|
||||||
|
@copy="emit('copy', screenshot)"
|
||||||
|
@edit="emit('edit', screenshot)"
|
||||||
|
@more="(event) => emit('more', screenshot, event)"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="screenshots.length === 0"
|
||||||
|
key="empty-group"
|
||||||
|
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.emptyGroup) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScreenshotSection>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { DropdownIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
InlineEditableText,
|
||||||
|
TagItem,
|
||||||
|
useDebugLogger,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
count: number
|
||||||
|
collapsed?: boolean
|
||||||
|
forceOpen?: boolean
|
||||||
|
hideHeader?: boolean
|
||||||
|
editable?: boolean
|
||||||
|
startEditing?: boolean
|
||||||
|
maxTitleLength?: number
|
||||||
|
validateTitle?: (value: string) => boolean
|
||||||
|
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
collapsed: false,
|
||||||
|
forceOpen: false,
|
||||||
|
hideHeader: false,
|
||||||
|
editable: false,
|
||||||
|
startEditing: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:collapsed', collapsed: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const debugLayout = useDebugLogger('Screenshots:Section')
|
||||||
|
const accordion = ref<InstanceType<typeof Accordion>>()
|
||||||
|
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
|
||||||
|
const titleModel = ref(props.title)
|
||||||
|
const messages = defineMessages({
|
||||||
|
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
|
||||||
|
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
debugLayout('toggle requested', {
|
||||||
|
title: props.title,
|
||||||
|
count: props.count,
|
||||||
|
isOpen: accordion.value?.isOpen,
|
||||||
|
})
|
||||||
|
if (accordion.value?.isOpen) {
|
||||||
|
accordion.value.close()
|
||||||
|
} else {
|
||||||
|
accordion.value?.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
debugLayout('opened', { title: props.title, count: props.count })
|
||||||
|
emit('update:collapsed', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
debugLayout('closed', { title: props.title, count: props.count })
|
||||||
|
emit('update:collapsed', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
debugLayout('mounted', {
|
||||||
|
title: props.title,
|
||||||
|
count: props.count,
|
||||||
|
collapsed: props.collapsed,
|
||||||
|
hideHeader: props.hideHeader,
|
||||||
|
forceOpen: props.forceOpen,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
debugLayout('unmounted', { title: props.title, count: props.count })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function startTitleEditing() {
|
||||||
|
if (!props.editable) return
|
||||||
|
await titleInput.value?.startEditing()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.title,
|
||||||
|
(title) => {
|
||||||
|
titleModel.value = title
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.startEditing,
|
||||||
|
async (shouldStartEditing) => {
|
||||||
|
if (!shouldStartEditing) return
|
||||||
|
await nextTick()
|
||||||
|
await startTitleEditing()
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="flex w-full flex-col">
|
||||||
|
<div
|
||||||
|
v-if="!hideHeader"
|
||||||
|
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||||
|
>
|
||||||
|
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
|
||||||
|
:aria-expanded="accordion?.isOpen"
|
||||||
|
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
|
||||||
|
@click.stop="toggle"
|
||||||
|
>
|
||||||
|
<DropdownIcon
|
||||||
|
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-all duration-300 group-hover/open-target:text-[var(--color-text-default)]"
|
||||||
|
:class="{ 'rotate-180': accordion?.isOpen }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<InlineEditableText
|
||||||
|
v-if="editable"
|
||||||
|
ref="titleInput"
|
||||||
|
v-model="titleModel"
|
||||||
|
activation-mode="manual"
|
||||||
|
class="!h-10 select-none text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
|
||||||
|
:edit-label="formatMessage(commonMessages.renameButton)"
|
||||||
|
max-width="24rem"
|
||||||
|
icon-text-class="select-none"
|
||||||
|
:max-length="maxTitleLength"
|
||||||
|
:validate="validateTitle"
|
||||||
|
:on-change="onTitleChange"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="select-none truncate text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
|
||||||
|
{{ count }}
|
||||||
|
</TagItem>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1" />
|
||||||
|
<slot name="actions" :start-editing="startTitleEditing" />
|
||||||
|
</div>
|
||||||
|
<Accordion
|
||||||
|
ref="accordion"
|
||||||
|
:open-by-default="hideHeader || !props.collapsed"
|
||||||
|
:force-open="forceOpen"
|
||||||
|
overflow-visible
|
||||||
|
class="w-full"
|
||||||
|
@on-open="handleOpen"
|
||||||
|
@on-close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="mt-2.5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowUpDownIcon, GridIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Combobox,
|
||||||
|
type ComboboxOption,
|
||||||
|
defineMessages,
|
||||||
|
StyledInput,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
|
||||||
|
const search = defineModel<string>('search', { required: true })
|
||||||
|
const sort = defineModel<string>('sort', { required: true })
|
||||||
|
const group = defineModel<string>('group', { required: true })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
sortOptions: ComboboxOption<string>[]
|
||||||
|
groupOptions: ComboboxOption<string>[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'new-group'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const messages = defineMessages({
|
||||||
|
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
|
||||||
|
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
|
||||||
|
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
|
||||||
|
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<StyledInput
|
||||||
|
v-model="search"
|
||||||
|
:icon="SearchIcon"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.search)"
|
||||||
|
clearable
|
||||||
|
wrapper-class="min-w-[16rem] flex-1"
|
||||||
|
/>
|
||||||
|
<Button @click="emit('new-group')">
|
||||||
|
<PlusIcon />
|
||||||
|
{{ formatMessage(messages.newGroup) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Combobox
|
||||||
|
v-model="sort"
|
||||||
|
class="w-max"
|
||||||
|
:options="sortOptions"
|
||||||
|
:show-icon-in-selected="false"
|
||||||
|
dropdown-min-width="160px"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<ArrowUpDownIcon
|
||||||
|
class="size-5 text-[var(--color-text-default)]"
|
||||||
|
:aria-label="formatMessage(messages.sortBy)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #selected="{ label }">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</template>
|
||||||
|
</Combobox>
|
||||||
|
<Combobox
|
||||||
|
v-model="group"
|
||||||
|
class="w-max"
|
||||||
|
:options="groupOptions"
|
||||||
|
:show-icon-in-selected="false"
|
||||||
|
dropdown-min-width="160px"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<GridIcon
|
||||||
|
class="size-5 text-[var(--color-text-default)]"
|
||||||
|
:aria-label="formatMessage(messages.groupBy)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #selected="{ label }">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</template>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { onBeforeUnmount, type Ref, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||||
|
|
||||||
|
type Point = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiveScreenshotDrag = {
|
||||||
|
primarySelectionKey: string
|
||||||
|
selectionKeys: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gatherDuration = 500
|
||||||
|
|
||||||
|
export type ScreenshotDragGatherItem = {
|
||||||
|
screenshot: InstanceScreenshot
|
||||||
|
selectionKey: string
|
||||||
|
rect: {
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||||
|
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScreenshotDragGather(screenshots: Ref<InstanceScreenshot[]>) {
|
||||||
|
const items = ref<ScreenshotDragGatherItem[]>([])
|
||||||
|
const target = ref<Point>({ x: 0, y: 0 })
|
||||||
|
const targetOffset = ref<Point>({ x: 0, y: 0 })
|
||||||
|
const isGathering = ref(false)
|
||||||
|
let cleanupTimer: number | undefined
|
||||||
|
|
||||||
|
const clearCleanupTimer = () => {
|
||||||
|
if (cleanupTimer !== undefined) {
|
||||||
|
clearTimeout(cleanupTimer)
|
||||||
|
cleanupTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
clearCleanupTimer()
|
||||||
|
items.value = []
|
||||||
|
targetOffset.value = { x: 0, y: 0 }
|
||||||
|
isGathering.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTarget = (pointer: Point) => {
|
||||||
|
target.value = {
|
||||||
|
x: pointer.x + targetOffset.value.x,
|
||||||
|
y: pointer.y + targetOffset.value.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => {
|
||||||
|
clear()
|
||||||
|
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return
|
||||||
|
|
||||||
|
const screenshotCards = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-screenshot-card]'),
|
||||||
|
)
|
||||||
|
const sourceCard = screenshotCards.find(
|
||||||
|
(card) => card.dataset.selectionKey === drag.primarySelectionKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sourceCard) {
|
||||||
|
const sourceRect = sourceCard.getBoundingClientRect()
|
||||||
|
targetOffset.value = {
|
||||||
|
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
|
||||||
|
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshotsByKey = new Map(
|
||||||
|
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
|
||||||
|
)
|
||||||
|
items.value = drag.selectionKeys.flatMap((selectionKey) => {
|
||||||
|
const screenshot = screenshotsByKey.get(selectionKey)
|
||||||
|
const card = screenshotCards.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0,
|
||||||
|
)
|
||||||
|
if (!screenshot || !card) return []
|
||||||
|
|
||||||
|
const rect = card.getBoundingClientRect()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
screenshot,
|
||||||
|
selectionKey,
|
||||||
|
rect: {
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
updateTarget(pointer)
|
||||||
|
isGathering.value = items.value.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
isGathering.value = false
|
||||||
|
clearCleanupTimer()
|
||||||
|
cleanupTimer = window.setTimeout(() => {
|
||||||
|
items.value = []
|
||||||
|
cleanupTimer = undefined
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(clearCleanupTimer)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
target,
|
||||||
|
isGathering,
|
||||||
|
start,
|
||||||
|
updateTarget,
|
||||||
|
clear,
|
||||||
|
finish,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,10 +22,11 @@ import { convertFileSrc } from '@tauri-apps/api/core'
|
|||||||
import { appDataDir, join } from '@tauri-apps/api/path'
|
import { appDataDir, join } from '@tauri-apps/api/path'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs'
|
import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { get, set } from '@/helpers/settings.ts'
|
import { get, set } from '@/helpers/settings.ts'
|
||||||
import { getOS } from '@/helpers/utils'
|
import { getOS } from '@/helpers/utils'
|
||||||
|
import { setUiScale, uiScale } from '@/helpers/ui-scale'
|
||||||
import { useTheming } from '@/store/state'
|
import { useTheming } from '@/store/state'
|
||||||
import {
|
import {
|
||||||
type AccentColor,
|
type AccentColor,
|
||||||
@@ -40,6 +41,19 @@ import {
|
|||||||
parseCustomAccentColor,
|
parseCustomAccentColor,
|
||||||
} from '@/store/theme.ts'
|
} from '@/store/theme.ts'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildFontFamilyOptions,
|
||||||
|
canonicalFontFamily,
|
||||||
|
DEFAULT_MONO_FONT_STACK,
|
||||||
|
DEFAULT_UI_FONT_STACK,
|
||||||
|
type FontFamilyOption,
|
||||||
|
type FontFamilyOptionOrDivider,
|
||||||
|
optionValueToFontSetting,
|
||||||
|
resolveFontFamily,
|
||||||
|
type SystemFontFamily,
|
||||||
|
} from '@/helpers/font-family.ts'
|
||||||
|
import { getSystemFontFamilies } from '@/helpers/system-fonts.ts'
|
||||||
|
|
||||||
import SettingsRow from './SettingsRow.vue'
|
import SettingsRow from './SettingsRow.vue'
|
||||||
import SettingsSection from './SettingsSection.vue'
|
import SettingsSection from './SettingsSection.vue'
|
||||||
|
|
||||||
@@ -209,6 +223,15 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
|
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
|
||||||
},
|
},
|
||||||
|
uiScaleTitle: {
|
||||||
|
id: 'app.appearance-settings.ui-scale.title',
|
||||||
|
defaultMessage: 'Launcher scale',
|
||||||
|
},
|
||||||
|
uiScaleDescription: {
|
||||||
|
id: 'app.appearance-settings.ui-scale.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Scale the whole launcher interface. A smaller scale fits more content, which helps when embedded pages are cut off.',
|
||||||
|
},
|
||||||
pageTransitionsTitle: {
|
pageTransitionsTitle: {
|
||||||
id: 'app.appearance-settings.page-transitions.title',
|
id: 'app.appearance-settings.page-transitions.title',
|
||||||
defaultMessage: 'Page transition animations',
|
defaultMessage: 'Page transition animations',
|
||||||
@@ -353,6 +376,63 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Hide the downloads button in the sidebar when there are no active download tasks.',
|
'Hide the downloads button in the sidebar when there are no active download tasks.',
|
||||||
},
|
},
|
||||||
|
fontsTitle: { id: 'app.appearance-settings.fonts.title', defaultMessage: 'Fonts' },
|
||||||
|
fontsDescription: {
|
||||||
|
id: 'app.appearance-settings.fonts.description',
|
||||||
|
defaultMessage: 'Choose the fonts the launcher interface and its logs are rendered in.',
|
||||||
|
},
|
||||||
|
uiFontTitle: { id: 'app.appearance-settings.fonts.ui-font', defaultMessage: 'Interface font' },
|
||||||
|
uiFontDescription: {
|
||||||
|
id: 'app.appearance-settings.fonts.ui-font-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Used across the launcher interface. Only fonts installed on this system are listed.',
|
||||||
|
},
|
||||||
|
monoFontTitle: {
|
||||||
|
id: 'app.appearance-settings.fonts.mono-font',
|
||||||
|
defaultMessage: 'Monospace font',
|
||||||
|
},
|
||||||
|
monoFontDescription: {
|
||||||
|
id: 'app.appearance-settings.fonts.mono-font-description',
|
||||||
|
defaultMessage: 'Used for logs, the server console, and other monospace content.',
|
||||||
|
},
|
||||||
|
fontDefault: {
|
||||||
|
id: 'app.appearance-settings.fonts.default',
|
||||||
|
defaultMessage: 'Launcher default',
|
||||||
|
},
|
||||||
|
fontMonospace: {
|
||||||
|
id: 'app.appearance-settings.fonts.monospace',
|
||||||
|
defaultMessage: 'Monospace',
|
||||||
|
},
|
||||||
|
fontMissing: {
|
||||||
|
id: 'app.appearance-settings.fonts.missing',
|
||||||
|
defaultMessage: 'Not installed',
|
||||||
|
},
|
||||||
|
fontSearchPlaceholder: {
|
||||||
|
id: 'app.appearance-settings.fonts.search-placeholder',
|
||||||
|
defaultMessage: 'Search installed fonts',
|
||||||
|
},
|
||||||
|
fontNoResults: {
|
||||||
|
id: 'app.appearance-settings.fonts.no-results',
|
||||||
|
defaultMessage: 'No matching fonts',
|
||||||
|
},
|
||||||
|
fontLoadFailed: {
|
||||||
|
id: 'app.appearance-settings.fonts.load-failed',
|
||||||
|
defaultMessage: "Couldn't read the list of installed fonts.",
|
||||||
|
},
|
||||||
|
fontRetry: { id: 'app.appearance-settings.fonts.retry', defaultMessage: 'Retry' },
|
||||||
|
fontReset: {
|
||||||
|
id: 'app.appearance-settings.fonts.reset',
|
||||||
|
defaultMessage: 'Reset',
|
||||||
|
},
|
||||||
|
fontPreviewLabel: { id: 'app.appearance-settings.fonts.preview', defaultMessage: 'Preview' },
|
||||||
|
fontUiPreviewSample: {
|
||||||
|
id: 'app.appearance-settings.fonts.ui-preview-sample',
|
||||||
|
defaultMessage: 'The quick brown fox jumps over the lazy dog.',
|
||||||
|
},
|
||||||
|
fontMonoPreviewSample: {
|
||||||
|
id: 'app.appearance-settings.fonts.mono-preview-sample',
|
||||||
|
defaultMessage: 'const x = () => 0; // monospace',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const os = ref(await getOS())
|
const os = ref(await getOS())
|
||||||
@@ -363,6 +443,84 @@ const customBackgroundPreview = computed(() =>
|
|||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const systemFonts = ref<SystemFontFamily[]>([])
|
||||||
|
const systemFontsLoading = ref(true)
|
||||||
|
const systemFontsFailed = ref(false)
|
||||||
|
|
||||||
|
async function loadSystemFonts() {
|
||||||
|
systemFontsLoading.value = true
|
||||||
|
systemFontsFailed.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
systemFonts.value = await getSystemFontFamilies()
|
||||||
|
} catch (error) {
|
||||||
|
systemFontsFailed.value = true
|
||||||
|
console.warn('Failed to read the list of installed fonts', error)
|
||||||
|
} finally {
|
||||||
|
systemFontsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedOptionLabel(options: FontFamilyOptionOrDivider[], value: string): string {
|
||||||
|
const option = options.find(
|
||||||
|
(entry): entry is FontFamilyOption => 'value' in entry && entry.value === value,
|
||||||
|
)
|
||||||
|
|
||||||
|
return option?.label ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
|
||||||
|
buildFontFamilyOptions(systemFonts.value, {
|
||||||
|
defaultLabel: formatMessage(messages.fontDefault),
|
||||||
|
missingLabel: formatMessage(messages.fontMissing),
|
||||||
|
selected: settings.value.ui_font,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const monoFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
|
||||||
|
buildFontFamilyOptions(systemFonts.value, {
|
||||||
|
defaultLabel: formatMessage(messages.fontDefault),
|
||||||
|
missingLabel: formatMessage(messages.fontMissing),
|
||||||
|
monospaceLabel: formatMessage(messages.fontMonospace),
|
||||||
|
selected: settings.value.mono_font,
|
||||||
|
groupMonospaced: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const uiFontSelection = computed({
|
||||||
|
get: () => canonicalFontFamily(systemFonts.value, settings.value.ui_font),
|
||||||
|
set: (value: string) => {
|
||||||
|
settings.value.ui_font = optionValueToFontSetting(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const monoFontSelection = computed({
|
||||||
|
get: () => canonicalFontFamily(systemFonts.value, settings.value.mono_font),
|
||||||
|
set: (value: string) => {
|
||||||
|
settings.value.mono_font = optionValueToFontSetting(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const uiFontSearchValue = computed(() =>
|
||||||
|
selectedOptionLabel(uiFontOptions.value, uiFontSelection.value),
|
||||||
|
)
|
||||||
|
const monoFontSearchValue = computed(() =>
|
||||||
|
selectedOptionLabel(monoFontOptions.value, monoFontSelection.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const uiFontPreview = computed(() =>
|
||||||
|
resolveFontFamily(settings.value.ui_font, DEFAULT_UI_FONT_STACK),
|
||||||
|
)
|
||||||
|
const monoFontPreview = computed(() =>
|
||||||
|
resolveFontFamily(settings.value.mono_font, DEFAULT_MONO_FONT_STACK),
|
||||||
|
)
|
||||||
|
|
||||||
|
function updateUiScale(value: number) {
|
||||||
|
const clamped = Math.min(200, Math.max(50, Math.round(value)))
|
||||||
|
setUiScale(clamped)
|
||||||
|
settings.value.ui_scale = clamped
|
||||||
|
}
|
||||||
|
|
||||||
const accentColorOptions: Array<{
|
const accentColorOptions: Array<{
|
||||||
value: AccentColor
|
value: AccentColor
|
||||||
color: string
|
color: string
|
||||||
@@ -473,6 +631,8 @@ watch(
|
|||||||
settings.value.transparent_background_blur,
|
settings.value.transparent_background_blur,
|
||||||
settings.value.sidebar_instance_count,
|
settings.value.sidebar_instance_count,
|
||||||
settings.value.close_behavior,
|
settings.value.close_behavior,
|
||||||
|
settings.value.ui_font,
|
||||||
|
settings.value.mono_font,
|
||||||
] as const,
|
] as const,
|
||||||
([
|
([
|
||||||
path,
|
path,
|
||||||
@@ -483,6 +643,8 @@ watch(
|
|||||||
transparentBlur,
|
transparentBlur,
|
||||||
sidebarInstanceCount,
|
sidebarInstanceCount,
|
||||||
closeBehavior,
|
closeBehavior,
|
||||||
|
uiFont,
|
||||||
|
monoFont,
|
||||||
]) => {
|
]) => {
|
||||||
themeStore.customBackgroundPath = path
|
themeStore.customBackgroundPath = path
|
||||||
themeStore.customBackgroundBlur = blur
|
themeStore.customBackgroundBlur = blur
|
||||||
@@ -493,10 +655,19 @@ watch(
|
|||||||
themeStore.setTransparentBackgroundClass()
|
themeStore.setTransparentBackgroundClass()
|
||||||
themeStore.sidebarInstanceCount = sidebarInstanceCount
|
themeStore.sidebarInstanceCount = sidebarInstanceCount
|
||||||
themeStore.closeBehavior = closeBehavior as CloseBehavior
|
themeStore.closeBehavior = closeBehavior as CloseBehavior
|
||||||
|
themeStore.uiFont = uiFont
|
||||||
|
themeStore.monoFont = monoFont
|
||||||
|
themeStore.setUiFont()
|
||||||
|
themeStore.setMonoFont()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.scope === 'interface') {
|
||||||
|
void loadSystemFonts()
|
||||||
|
}
|
||||||
|
})
|
||||||
watch(
|
watch(
|
||||||
settings,
|
settings,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -1020,6 +1191,43 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
<SettingsRow stacked>
|
||||||
|
<template #label>
|
||||||
|
<span id="settings-target-appearance-ui-scale" tabindex="-1">
|
||||||
|
{{ formatMessage(messages.uiScaleTitle) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #description>{{ formatMessage(messages.uiScaleDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<div class="flex w-full flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in [100, 110, 125, 150]"
|
||||||
|
:key="preset"
|
||||||
|
type="button"
|
||||||
|
class="min-w-16 rounded-lg border border-solid px-3 py-1.5 text-sm font-semibold transition-colors"
|
||||||
|
:class="
|
||||||
|
uiScale === preset
|
||||||
|
? 'border-brand bg-brand-highlight text-brand'
|
||||||
|
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
|
||||||
|
"
|
||||||
|
@click="updateUiScale(preset)"
|
||||||
|
>
|
||||||
|
{{ preset }}%
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="ui-scale"
|
||||||
|
:model-value="uiScale"
|
||||||
|
:min="50"
|
||||||
|
:max="200"
|
||||||
|
:step="5"
|
||||||
|
unit="%"
|
||||||
|
@update:model-value="(value) => updateUiScale(Number(value))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
<SettingsRow>
|
<SettingsRow>
|
||||||
<template #label>
|
<template #label>
|
||||||
<span id="settings-target-appearance-page-transitions" tabindex="-1">
|
<span id="settings-target-appearance-page-transitions" tabindex="-1">
|
||||||
@@ -1095,6 +1303,118 @@ watch(
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection v-if="props.scope === 'interface'">
|
||||||
|
<template #header>
|
||||||
|
<h2
|
||||||
|
id="settings-target-appearance-fonts"
|
||||||
|
tabindex="-1"
|
||||||
|
class="m-0 text-lg font-semibold text-contrast"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.fontsTitle) }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
|
||||||
|
{{ formatMessage(messages.fontsDescription) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<SettingsRow stacked>
|
||||||
|
<template #label>
|
||||||
|
<span id="settings-target-appearance-ui-font" tabindex="-1">
|
||||||
|
{{ formatMessage(messages.uiFontTitle) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #description>{{ formatMessage(messages.uiFontDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<div class="flex w-full flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<Combobox
|
||||||
|
v-model="uiFontSelection"
|
||||||
|
:options="uiFontOptions"
|
||||||
|
searchable
|
||||||
|
:search-value="uiFontSearchValue"
|
||||||
|
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
|
||||||
|
:no-options-message="formatMessage(messages.fontNoResults)"
|
||||||
|
show-no-options-when-empty
|
||||||
|
:disabled="systemFontsLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="quiet"
|
||||||
|
:disabled="settings.ui_font === null"
|
||||||
|
@click="settings.ui_font = null"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.fontReset) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-secondary">
|
||||||
|
{{ formatMessage(messages.fontPreviewLabel) }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-base text-contrast"
|
||||||
|
:style="{ fontFamily: uiFontPreview }"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.fontUiPreviewSample) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow stacked>
|
||||||
|
<template #label>
|
||||||
|
<span id="settings-target-appearance-mono-font" tabindex="-1">
|
||||||
|
{{ formatMessage(messages.monoFontTitle) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #description>{{ formatMessage(messages.monoFontDescription) }}</template>
|
||||||
|
<template #control>
|
||||||
|
<div class="flex w-full flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<Combobox
|
||||||
|
v-model="monoFontSelection"
|
||||||
|
:options="monoFontOptions"
|
||||||
|
searchable
|
||||||
|
:search-value="monoFontSearchValue"
|
||||||
|
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
|
||||||
|
:no-options-message="formatMessage(messages.fontNoResults)"
|
||||||
|
show-no-options-when-empty
|
||||||
|
:disabled="systemFontsLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="quiet"
|
||||||
|
:disabled="settings.mono_font === null"
|
||||||
|
@click="settings.mono_font = null"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.fontReset) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs text-secondary">
|
||||||
|
{{ formatMessage(messages.fontPreviewLabel) }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-xs text-contrast"
|
||||||
|
:style="{ fontFamily: monoFontPreview }"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.fontMonoPreviewSample) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<div
|
||||||
|
v-if="systemFontsFailed"
|
||||||
|
class="flex items-center justify-between gap-3 px-4 pb-4 text-sm text-secondary"
|
||||||
|
>
|
||||||
|
<span>{{ formatMessage(messages.fontLoadFailed) }}</span>
|
||||||
|
<Button type="quiet" @click="loadSystemFonts">
|
||||||
|
{{ formatMessage(messages.fontRetry) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection v-if="props.scope === 'content-downloads'">
|
<SettingsSection v-if="props.scope === 'content-downloads'">
|
||||||
<SettingsRow>
|
<SettingsRow>
|
||||||
<template #label>
|
<template #label>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { FolderIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Admonition,
|
||||||
|
Button,
|
||||||
|
defineMessages,
|
||||||
|
injectFilePicker,
|
||||||
|
injectNotificationManager,
|
||||||
|
useFormatBytes,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BackupRepositoryStatus,
|
||||||
|
getBackupRepositoryStatus,
|
||||||
|
listBackupOperations,
|
||||||
|
listenBackupProgress,
|
||||||
|
moveBackupRepository,
|
||||||
|
} from '@/helpers/instance-backup'
|
||||||
|
|
||||||
|
import SettingsRow from './SettingsRow.vue'
|
||||||
|
import SettingsSection from './SettingsSection.vue'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const formatBytes = useFormatBytes()
|
||||||
|
const filePicker = injectFilePicker()
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
const status = ref<BackupRepositoryStatus | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const moving = ref(false)
|
||||||
|
const movedBytes = ref(0)
|
||||||
|
const moveTotalBytes = ref(0)
|
||||||
|
const movingOperationId = ref<string | null>(null)
|
||||||
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'settings.backups.repository.title', defaultMessage: 'Backup repository' },
|
||||||
|
description: {
|
||||||
|
id: 'settings.backups.repository.description',
|
||||||
|
defaultMessage: 'Deduplicated instance backup data and its independent index database.',
|
||||||
|
},
|
||||||
|
location: { id: 'settings.backups.repository.location', defaultMessage: 'Location' },
|
||||||
|
locationDescription: {
|
||||||
|
id: 'settings.backups.repository.location-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Changing the location moves the complete repository. Choose a new or empty folder.',
|
||||||
|
},
|
||||||
|
change: { id: 'settings.backups.repository.change', defaultMessage: 'Change folder' },
|
||||||
|
moving: { id: 'settings.backups.repository.moving', defaultMessage: 'Moving repository...' },
|
||||||
|
movingProgress: {
|
||||||
|
id: 'settings.backups.repository.moving-progress',
|
||||||
|
defaultMessage: 'Moving repository: {processed} of {total}',
|
||||||
|
},
|
||||||
|
usage: { id: 'settings.backups.repository.usage', defaultMessage: 'Storage usage' },
|
||||||
|
usageDescription: {
|
||||||
|
id: 'settings.backups.repository.usage-description',
|
||||||
|
defaultMessage:
|
||||||
|
'{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}.',
|
||||||
|
},
|
||||||
|
unavailable: {
|
||||||
|
id: 'settings.backups.repository.unavailable',
|
||||||
|
defaultMessage: 'Repository unavailable',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
status.value = await getBackupRepositoryStatus()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeLocation() {
|
||||||
|
const selection = await filePicker.pickFolder?.()
|
||||||
|
if (!selection) return
|
||||||
|
moving.value = true
|
||||||
|
movedBytes.value = 0
|
||||||
|
moveTotalBytes.value = 0
|
||||||
|
try {
|
||||||
|
movingOperationId.value = await moveBackupRepository(selection.path)
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
moving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
unlisten = await listenBackupProgress((event) => {
|
||||||
|
if (event.operationType !== 'repository_move') return
|
||||||
|
if (movingOperationId.value && event.operationId !== movingOperationId.value) return
|
||||||
|
movingOperationId.value = event.operationId
|
||||||
|
moving.value = !event.finalState
|
||||||
|
movedBytes.value = event.processedBytes
|
||||||
|
moveTotalBytes.value = event.totalBytes
|
||||||
|
if (event.finalState) void refresh()
|
||||||
|
})
|
||||||
|
const activeMove = (await listBackupOperations(undefined, true)).find(
|
||||||
|
(operation) => operation.operation_type === 'repository_move',
|
||||||
|
)
|
||||||
|
if (activeMove) {
|
||||||
|
movingOperationId.value = activeMove.id
|
||||||
|
moving.value = true
|
||||||
|
movedBytes.value = activeMove.processed_bytes
|
||||||
|
moveTotalBytes.value = activeMove.total_bytes
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => unlisten?.())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SettingsSection
|
||||||
|
:title="formatMessage(messages.title)"
|
||||||
|
:description="formatMessage(messages.description)"
|
||||||
|
>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.location) }}</template>
|
||||||
|
<template #description>
|
||||||
|
<span class="break-all">{{ status?.path ?? '...' }}</span>
|
||||||
|
<span class="mt-1 block">{{ formatMessage(messages.locationDescription) }}</span>
|
||||||
|
</template>
|
||||||
|
<template #control>
|
||||||
|
<Button type="outlined" :disabled="loading || moving" @click="changeLocation"
|
||||||
|
><FolderIcon />
|
||||||
|
{{
|
||||||
|
formatMessage(
|
||||||
|
moving && moveTotalBytes > 0
|
||||||
|
? messages.movingProgress
|
||||||
|
: moving
|
||||||
|
? messages.moving
|
||||||
|
: messages.change,
|
||||||
|
{
|
||||||
|
processed: formatBytes(movedBytes),
|
||||||
|
total: formatBytes(moveTotalBytes),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow v-if="status">
|
||||||
|
<template #label>{{ formatMessage(messages.usage) }}</template>
|
||||||
|
<template #description>
|
||||||
|
{{
|
||||||
|
formatMessage(messages.usageDescription, {
|
||||||
|
stored: formatBytes(status.stored_size),
|
||||||
|
logical: formatBytes(status.logical_size),
|
||||||
|
count: status.snapshot_count,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
<Admonition
|
||||||
|
v-if="status && !status.available"
|
||||||
|
type="critical"
|
||||||
|
:header="formatMessage(messages.unavailable)"
|
||||||
|
>
|
||||||
|
{{ status.error }}
|
||||||
|
</Admonition>
|
||||||
|
</SettingsSection>
|
||||||
|
</template>
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Toggle,
|
Toggle,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { inject, watch } from 'vue'
|
import { inject, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||||
import { isDev } from '@/helpers/utils'
|
import { isDev } from '@/helpers/utils'
|
||||||
@@ -54,6 +54,25 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const featureFlagLabels: Record<FeatureFlag, string> = {
|
||||||
|
project_background: '项目背景',
|
||||||
|
page_path: '页面路径',
|
||||||
|
worlds_tab: '世界标签页',
|
||||||
|
worlds_in_home: '主页显示世界',
|
||||||
|
server_project_qa: '服务器项目问答',
|
||||||
|
show_version_environment_column: '显示版本环境列',
|
||||||
|
server_ram_as_bytes_always_on: '服务器内存始终按字节显示',
|
||||||
|
always_show_app_controls: '始终显示窗口控件',
|
||||||
|
skip_non_essential_warnings: '跳过非必要警告',
|
||||||
|
skip_unknown_pack_warning: '跳过未知整合包警告',
|
||||||
|
i18n_debug: '国际化调试',
|
||||||
|
show_instance_play_time: '显示实例游玩时间',
|
||||||
|
page_transitions: '页面切换动画',
|
||||||
|
advanced_filters_collapsed: '高级筛选默认折叠',
|
||||||
|
multiplayer_in_app: '多人联机',
|
||||||
|
auto_install_dependencies: '自动安装依赖',
|
||||||
|
}
|
||||||
|
|
||||||
const settings = ref(await getSettings())
|
const settings = ref(await getSettings())
|
||||||
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
|
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
|
||||||
|
|
||||||
@@ -85,7 +104,7 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsRow v-for="option in options" :key="option">
|
<SettingsRow v-for="option in options" :key="option">
|
||||||
<template #label>{{ option.replaceAll('_', ' ') }}</template>
|
<template #label>{{ featureFlagLabels[option] ?? option }}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -124,4 +143,4 @@ watch(
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</template>
|
</template>
|
||||||
@@ -253,6 +253,15 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'When CurseForge does not provide a download address, derive its CDN address and try downloading the file automatically. Disable this to use the manual download workflow.',
|
'When CurseForge does not provide a download address, derive its CDN address and try downloading the file automatically. Disable this to use the manual download workflow.',
|
||||||
},
|
},
|
||||||
|
ignoreSslErrors: {
|
||||||
|
id: 'app.settings.resources.ignore-ssl-errors',
|
||||||
|
defaultMessage: 'Ignore SSL certificate errors',
|
||||||
|
},
|
||||||
|
ignoreSslErrorsDescription: {
|
||||||
|
id: 'app.settings.resources.ignore-ssl-errors-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Skip TLS certificate validation for launcher network requests. Only enable this if you trust your network and understand the security risk.',
|
||||||
|
},
|
||||||
mojangAuthService: {
|
mojangAuthService: {
|
||||||
id: 'app.settings.resources.mojang-auth-service',
|
id: 'app.settings.resources.mojang-auth-service',
|
||||||
defaultMessage: 'Mojang authentication service',
|
defaultMessage: 'Mojang authentication service',
|
||||||
@@ -923,6 +932,15 @@ function validateMinecraftDirectory(value) {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
<SettingsRow>
|
||||||
|
<template #label>{{ formatMessage(messages.ignoreSslErrors) }}</template>
|
||||||
|
<template #description>
|
||||||
|
{{ formatMessage(messages.ignoreSslErrorsDescription) }}
|
||||||
|
</template>
|
||||||
|
<template #control>
|
||||||
|
<Toggle id="ignore-ssl-errors" v-model="settings.ignore_ssl_errors" />
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection v-if="props.scope === 'content-downloads'">
|
<SettingsSection v-if="props.scope === 'content-downloads'">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import BackupRepositorySettings from './BackupRepositorySettings.vue'
|
||||||
import ResourceManagementSettings from './ResourceManagementSettings.vue'
|
import ResourceManagementSettings from './ResourceManagementSettings.vue'
|
||||||
import StorageSettings from './StorageSettings.vue'
|
import StorageSettings from './StorageSettings.vue'
|
||||||
</script>
|
</script>
|
||||||
@@ -6,6 +7,7 @@ import StorageSettings from './StorageSettings.vue'
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<ResourceManagementSettings scope="storage-backups" />
|
<ResourceManagementSettings scope="storage-backups" />
|
||||||
|
<BackupRepositorySettings />
|
||||||
<StorageSettings />
|
<StorageSettings />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -12,13 +12,13 @@ import {
|
|||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||||
import { inject, nextTick, ref, watch } from 'vue'
|
import { nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
import UpdateAnnouncementHistory from '@/components/ui/announcement/UpdateAnnouncementHistory.vue'
|
import UpdateAnnouncementHistory from '@/components/ui/announcement/UpdateAnnouncementHistory.vue'
|
||||||
import {
|
import {
|
||||||
betaDatabaseExists,
|
betaDatabaseExists,
|
||||||
copyReleaseDatabaseToBeta,
|
|
||||||
copyDatabaseBetweenChannels,
|
copyDatabaseBetweenChannels,
|
||||||
|
copyReleaseDatabaseToBeta,
|
||||||
getCurrentAppDatabasePath,
|
getCurrentAppDatabasePath,
|
||||||
getUpdateChannel,
|
getUpdateChannel,
|
||||||
getUpdatePreferences,
|
getUpdatePreferences,
|
||||||
@@ -446,11 +446,14 @@ async function confirmDatabaseOperation() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error)
|
||||||
handleError(
|
handleError(
|
||||||
new Error(
|
new Error(
|
||||||
targetChannel === activeChannel
|
`${
|
||||||
? formatMessage(messages.databaseOperationActiveTarget)
|
targetChannel === activeChannel
|
||||||
: formatMessage(messages.databaseOperationFailed),
|
? formatMessage(messages.databaseOperationActiveTarget)
|
||||||
|
: formatMessage(messages.databaseOperationFailed)
|
||||||
|
}: ${detail}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -468,7 +471,7 @@ function cancelDatabaseOperation() {
|
|||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<SettingsSection :title="formatMessage(messages.title)">
|
<SettingsSection :title="formatMessage(messages.title)">
|
||||||
<div class="update-channel-panel">
|
<div class="update-channel-panel">
|
||||||
<p id="settings-target-updates-channel" class="m-0 text-sm leading-[1.45] text-secondary">
|
<p id="settings-target-updates-source" class="m-0 text-sm leading-[1.45] text-secondary">
|
||||||
{{ formatMessage(messages.description) }}
|
{{ formatMessage(messages.description) }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RefreshCwIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
CheckCircleButton,
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
NewModal,
|
||||||
|
StyledInput,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { computed, ref, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import { getInstanceIconUrl } from '@/helpers/instance'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
sources: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon_path?: string | null
|
||||||
|
eligible: boolean
|
||||||
|
}[]
|
||||||
|
loading?: boolean
|
||||||
|
pending?: boolean
|
||||||
|
error?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
confirm: []
|
||||||
|
close: []
|
||||||
|
retry: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedInstanceId = defineModel<string>({ required: true })
|
||||||
|
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||||
|
const search = ref('')
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
ineligible: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.ineligible',
|
||||||
|
defaultMessage: 'You cannot choose this instance as a sync source',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.search-placeholder',
|
||||||
|
defaultMessage: 'Search instance',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.no-instances-found',
|
||||||
|
defaultMessage: 'No instances found',
|
||||||
|
},
|
||||||
|
sync: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.sync',
|
||||||
|
defaultMessage: 'Sync',
|
||||||
|
},
|
||||||
|
loadError: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.load-error',
|
||||||
|
defaultMessage: 'Could not load sync sources. Please try again.',
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
id: 'app.settings.synced-options.choose-sync-source.retry',
|
||||||
|
defaultMessage: 'Try again',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredSources = computed(() => {
|
||||||
|
const query = search.value.trim().toLocaleLowerCase()
|
||||||
|
return props.sources.filter((source) => source.name.toLocaleLowerCase().includes(query))
|
||||||
|
})
|
||||||
|
const canConfirm = computed(
|
||||||
|
() =>
|
||||||
|
!props.loading &&
|
||||||
|
!props.pending &&
|
||||||
|
!props.error &&
|
||||||
|
props.sources.some((source) => source.id === selectedInstanceId.value && source.eligible),
|
||||||
|
)
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
search.value = ''
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
return modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="modal"
|
||||||
|
:header="title"
|
||||||
|
no-padding
|
||||||
|
actions-divider
|
||||||
|
max-width="560px"
|
||||||
|
width="560px"
|
||||||
|
:disable-close="pending"
|
||||||
|
:on-after-hide="() => emit('close')"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="m-0 border-0 border-b border-solid border-surface-5 p-6 text-[var(--color-text-default)]"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 px-6 py-4">
|
||||||
|
<StyledInput
|
||||||
|
v-model="search"
|
||||||
|
:icon="SearchIcon"
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="formatMessage(messages.search)"
|
||||||
|
:aria-label="formatMessage(messages.search)"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex flex-1 items-center justify-center" aria-busy="true">
|
||||||
|
<SpinnerIcon class="size-5 animate-spin text-[var(--color-text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-3">
|
||||||
|
<p role="alert" class="m-0 text-center text-[var(--color-text-default)]">
|
||||||
|
{{ formatMessage(messages.loadError) }}
|
||||||
|
</p>
|
||||||
|
<Button @click="emit('retry')">{{ formatMessage(messages.retry) }}</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="filteredSources.length === 0"
|
||||||
|
class="flex flex-1 items-center justify-center text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.empty) }}
|
||||||
|
</div>
|
||||||
|
<div v-else role="radiogroup" :aria-label="title" class="flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
v-for="source in filteredSources"
|
||||||
|
:key="source.id"
|
||||||
|
v-tooltip="!source.eligible ? formatMessage(messages.ineligible) : undefined"
|
||||||
|
>
|
||||||
|
<CheckCircleButton
|
||||||
|
:checked="selectedInstanceId === source.id"
|
||||||
|
:disabled="pending || !source.eligible"
|
||||||
|
class="min-h-10"
|
||||||
|
@click="selectedInstanceId = source.id"
|
||||||
|
>
|
||||||
|
<span class="size-5 shrink-0 overflow-hidden rounded-[6px]">
|
||||||
|
<Avatar
|
||||||
|
:src="getInstanceIconUrl(source.icon_path)"
|
||||||
|
:alt="source.name"
|
||||||
|
:tint-by="source.id"
|
||||||
|
size="1.25rem"
|
||||||
|
no-shadow
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{{ source.name }}</span>
|
||||||
|
</CheckCircleButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-end gap-2 p-2">
|
||||||
|
<Button type="outlined" :disabled="pending" @click="hide">
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="brand"
|
||||||
|
:disabled="!canConfirm"
|
||||||
|
:loading="pending"
|
||||||
|
@click="canConfirm && emit('confirm')"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon :class="{ 'animate-spin': pending }" aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.sync) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
remove_synced_pack,
|
||||||
|
set_synced_pack_enabled,
|
||||||
|
syncedPackKeys,
|
||||||
|
syncedPackQueryOptions,
|
||||||
|
type SyncedPackType,
|
||||||
|
} from '@/helpers/synced-packs'
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const type = ref<SyncedPackType>('resourcepack')
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const messages = defineMessages({
|
||||||
|
resourcePacks: {
|
||||||
|
id: 'app.settings.synced-packs.resource-packs',
|
||||||
|
defaultMessage: 'Resource packs',
|
||||||
|
},
|
||||||
|
dataPacks: { id: 'app.settings.synced-packs.data-packs', defaultMessage: 'Data packs' },
|
||||||
|
enable: { id: 'app.settings.synced-packs.enable', defaultMessage: 'Enable' },
|
||||||
|
disable: { id: 'app.settings.synced-packs.disable', defaultMessage: 'Disable' },
|
||||||
|
remove: { id: 'app.settings.synced-packs.remove', defaultMessage: 'Remove' },
|
||||||
|
empty: { id: 'app.settings.synced-packs.empty', defaultMessage: 'No synced packs' },
|
||||||
|
})
|
||||||
|
const query = useQuery(computed(() => syncedPackQueryOptions(type.value)))
|
||||||
|
const packs = computed(() => query.value.data.value ?? [])
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (request: { id: string; enabled?: boolean; remove?: boolean }) => {
|
||||||
|
if (request.remove) await remove_synced_pack(request.id)
|
||||||
|
else await set_synced_pack_enabled(request.id, request.enabled ?? true)
|
||||||
|
},
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function show(nextType: SyncedPackType) {
|
||||||
|
type.value = nextType
|
||||||
|
modal.value?.show()
|
||||||
|
await queryClient.invalidateQueries({ queryKey: syncedPackKeys.list(nextType) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="modal"
|
||||||
|
:header="formatMessage(type === 'resourcepack' ? messages.resourcePacks : messages.dataPacks)"
|
||||||
|
max-width="42rem"
|
||||||
|
>
|
||||||
|
<div v-if="packs.length" class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="pack in packs"
|
||||||
|
:key="pack.id"
|
||||||
|
class="flex items-center gap-3 border-b border-solid border-surface-4 py-2"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{{ pack.file_name }}</span>
|
||||||
|
<Button
|
||||||
|
:disabled="mutation.isPending.value"
|
||||||
|
@click="mutation.mutate({ id: pack.id, enabled: !pack.enabled })"
|
||||||
|
>{{ formatMessage(pack.enabled ? messages.disable : messages.enable) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="red"
|
||||||
|
:disabled="mutation.isPending.value"
|
||||||
|
@click="mutation.mutate({ id: pack.id, remove: true })"
|
||||||
|
>{{ formatMessage(messages.remove) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.empty) }}</p>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SaveIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
injectNotificationManager,
|
||||||
|
NewModal,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import StudioEditor from '@/components/instance/studio/StudioEditor.vue'
|
||||||
|
import { set_command_history } from '@/helpers/instance'
|
||||||
|
import { commandHistoryQueryOptions, syncedOptionsKeys } from '@/helpers/synced-options'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||||
|
const commandHistory = ref('')
|
||||||
|
const historyQuery = useQuery({ ...commandHistoryQueryOptions(), enabled: false })
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: set_command_history,
|
||||||
|
onSuccess: (history) => {
|
||||||
|
queryClient.setQueryData(syncedOptionsKeys.commandHistory, history)
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
commandHistoryEditorTitle: {
|
||||||
|
id: 'app.settings.synced-options.command-history.editor-title',
|
||||||
|
defaultMessage: 'Edit command history',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
const result = await historyQuery.refetch()
|
||||||
|
if (result.isError) {
|
||||||
|
handleError(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.isSuccess) return
|
||||||
|
commandHistory.value = result.data
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCommandHistory() {
|
||||||
|
if (saveMutation.isPending.value) return
|
||||||
|
try {
|
||||||
|
await saveMutation.mutateAsync(commandHistory.value)
|
||||||
|
await nextTick()
|
||||||
|
modal.value?.hide()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="modal"
|
||||||
|
:header="formatMessage(messages.commandHistoryEditorTitle)"
|
||||||
|
:disable-close="saveMutation.isPending.value"
|
||||||
|
no-padding
|
||||||
|
actions-divider
|
||||||
|
max-width="700px"
|
||||||
|
width="700px"
|
||||||
|
>
|
||||||
|
<StudioEditor
|
||||||
|
v-model:content="commandHistory"
|
||||||
|
file-path="command_history.txt"
|
||||||
|
language="plaintext"
|
||||||
|
class="h-[420px] text-sm"
|
||||||
|
/>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="outlined" @click="modal?.hide()">
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</Button>
|
||||||
|
<Button type="colored" :loading="saveMutation.isPending.value" @click="saveCommandHistory">
|
||||||
|
<SaveIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.saveButton) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button } from '@modrinth/ui'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
label: string
|
||||||
|
onLabel: string
|
||||||
|
offLabel: string
|
||||||
|
placeholder?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:model-value': [value: boolean]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-tooltip="modelValue === undefined ? placeholder : undefined"
|
||||||
|
role="group"
|
||||||
|
:aria-label="label"
|
||||||
|
class="inline-flex h-9 items-center gap-0.5 rounded-xl border border-solid border-surface-5 p-px shadow-[0_1px_3px_rgba(0,0,0,0.15)]"
|
||||||
|
:class="{ 'opacity-50': disabled }"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="quiet"
|
||||||
|
size="sm"
|
||||||
|
:interaction="modelValue === true ? 'none' : 'surface'"
|
||||||
|
class="boolean-control-on !rounded-[10px] !px-3 !font-medium disabled:!opacity-100"
|
||||||
|
:class="modelValue === true ? '!text-green' : '!text-[var(--color-text-primary)]'"
|
||||||
|
:aria-pressed="modelValue === true"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="emit('update:model-value', true)"
|
||||||
|
>
|
||||||
|
{{ onLabel }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="quiet"
|
||||||
|
size="sm"
|
||||||
|
:interaction="modelValue === false ? 'none' : 'surface'"
|
||||||
|
class="boolean-control-off !rounded-[10px] !px-3 !font-medium !text-[var(--color-text-primary)] disabled:!opacity-100"
|
||||||
|
:aria-pressed="modelValue === false"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="emit('update:model-value', false)"
|
||||||
|
>
|
||||||
|
{{ offLabel }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.boolean-control-on[aria-pressed='true'] {
|
||||||
|
background-color: color-mix(in srgb, var(--color-green) 30%, var(--surface-3));
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-green) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boolean-control-off[aria-pressed='true'] {
|
||||||
|
background-color: var(--surface-4);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--surface-5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
import { toRaw } from 'vue'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EditableGameSetting,
|
||||||
|
GameOptionCanonicalValue,
|
||||||
|
GameSettingChange,
|
||||||
|
GameSettingsEditorState,
|
||||||
|
} from '@/helpers/game-options'
|
||||||
|
|
||||||
|
function clonePlainValue(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return toRaw(value).map(clonePlainValue)
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(toRaw(value)).map(([key, nestedValue]) => [key, clonePlainValue(nestedValue)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneGameSettingsState(state: GameSettingsEditorState): GameSettingsEditorState {
|
||||||
|
return clonePlainValue(state) as GameSettingsEditorState
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalValuesEqual(
|
||||||
|
left: GameOptionCanonicalValue | null | undefined,
|
||||||
|
right: GameOptionCanonicalValue | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gameSettingChanges(
|
||||||
|
base: GameSettingsEditorState | null,
|
||||||
|
draft: GameSettingsEditorState | null,
|
||||||
|
forcedValueOptionIds: ReadonlySet<string> = new Set(),
|
||||||
|
): GameSettingChange[] {
|
||||||
|
if (!base || !draft) return []
|
||||||
|
|
||||||
|
const baseSettings = new Map(base.settings.map((setting) => [setting.option_id, setting]))
|
||||||
|
return draft.settings.flatMap((setting) => {
|
||||||
|
const previous = baseSettings.get(setting.option_id)
|
||||||
|
if (!previous) return []
|
||||||
|
|
||||||
|
const syncChanged = previous.sync_enabled !== setting.sync_enabled
|
||||||
|
const valueChanged = !canonicalValuesEqual(previous.canonical_value, setting.canonical_value)
|
||||||
|
const promoteLocalValue =
|
||||||
|
forcedValueOptionIds.has(setting.option_id) &&
|
||||||
|
previous.value_state !== 'canonical' &&
|
||||||
|
setting.canonical_value !== null &&
|
||||||
|
setting.canonical_value !== undefined
|
||||||
|
if (!syncChanged && !valueChanged && !promoteLocalValue) return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
option_id: setting.option_id,
|
||||||
|
base_option_revision: previous.option_revision,
|
||||||
|
...(syncChanged ? { sync_enabled: setting.sync_enabled } : {}),
|
||||||
|
...(valueChanged || promoteLocalValue
|
||||||
|
? { canonical_value: setting.canonical_value ?? null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gameSettingNumberScale(setting: EditableGameSetting): number {
|
||||||
|
if (setting.editor.unit !== 'percent') return 1
|
||||||
|
return setting.option_id === 'sensitivity' ? 200 : 100
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalValueText(setting: EditableGameSetting): string {
|
||||||
|
const value = setting.canonical_value
|
||||||
|
if (!value) return ''
|
||||||
|
|
||||||
|
switch (value.type) {
|
||||||
|
case 'bool':
|
||||||
|
return value.value ? 'true' : 'false'
|
||||||
|
case 'integer':
|
||||||
|
case 'decimal':
|
||||||
|
return setting.editor.unit === 'percent'
|
||||||
|
? String(Number((Number(value.value) * gameSettingNumberScale(setting)).toFixed(8)))
|
||||||
|
: String(value.value)
|
||||||
|
case 'string_list':
|
||||||
|
return value.value.join(', ')
|
||||||
|
default:
|
||||||
|
return value.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalBooleanValue(setting: EditableGameSetting): boolean | undefined {
|
||||||
|
return setting.canonical_value?.type === 'bool' ? setting.canonical_value.value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKeybindSetting(setting: EditableGameSetting): boolean {
|
||||||
|
return (
|
||||||
|
setting.editor.type === 'key_binding' ||
|
||||||
|
(setting.editor.type === 'external_raw' && !!setting.raw_key?.startsWith('key_'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalValueFromInput(
|
||||||
|
setting: EditableGameSetting,
|
||||||
|
value: string | number | boolean | undefined,
|
||||||
|
): GameOptionCanonicalValue | null {
|
||||||
|
if (value === undefined || value === '') return null
|
||||||
|
|
||||||
|
switch (setting.editor.type) {
|
||||||
|
case 'bool':
|
||||||
|
return { type: 'bool', value: Boolean(value) }
|
||||||
|
case 'integer': {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isSafeInteger(parsed) ? { type: 'integer', value: parsed } : null
|
||||||
|
}
|
||||||
|
case 'decimal': {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) return null
|
||||||
|
return {
|
||||||
|
type: 'decimal',
|
||||||
|
value:
|
||||||
|
setting.editor.unit === 'percent'
|
||||||
|
? String(Number((parsed / gameSettingNumberScale(setting)).toFixed(8)))
|
||||||
|
: String(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'enum':
|
||||||
|
return { type: 'enum', value: String(value) }
|
||||||
|
case 'string_list':
|
||||||
|
return {
|
||||||
|
type: 'string_list',
|
||||||
|
value: String(value)
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
}
|
||||||
|
case 'key_binding':
|
||||||
|
return { type: 'key_binding', value: String(value) }
|
||||||
|
case 'external_raw':
|
||||||
|
return { type: 'external_raw', value: String(value) }
|
||||||
|
default:
|
||||||
|
return { type: 'text', value: String(value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settingSearchText(
|
||||||
|
setting: EditableGameSetting,
|
||||||
|
label: string,
|
||||||
|
description: string,
|
||||||
|
): string {
|
||||||
|
return [label, description, setting.option_id, setting.raw_key]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settingCanBeEnabled(setting: EditableGameSetting): boolean {
|
||||||
|
return (
|
||||||
|
!setting.controlled &&
|
||||||
|
!setting.validation_error &&
|
||||||
|
!['mixed', 'unset', 'invalid'].includes(setting.value_state) &&
|
||||||
|
(setting.compatibility.total_participating === 0 || setting.compatibility.will_receive > 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
EyeIcon,
|
||||||
|
LanguagesIcon,
|
||||||
|
LinkIcon,
|
||||||
|
MessageIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SaveIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
ShirtIcon,
|
||||||
|
TagCategoryAudioIcon,
|
||||||
|
TagCategoryGamepad2Icon,
|
||||||
|
UnlinkIcon,
|
||||||
|
WrenchIcon,
|
||||||
|
XIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
FloatingActionBar,
|
||||||
|
StyledInput,
|
||||||
|
TabbedModal,
|
||||||
|
type TabbedModalTab,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { computed, inject, ref, watch } from 'vue'
|
||||||
|
import { type RouteLocationRaw, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import type { EditableGameSetting, GameSettingCategory } from '@/helpers/game-options'
|
||||||
|
import { injectInstanceSettings } from '@/pages/instance/components/settings-modal/instance-settings-context'
|
||||||
|
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
|
||||||
|
|
||||||
|
import {
|
||||||
|
canonicalValueText,
|
||||||
|
isKeybindSetting,
|
||||||
|
settingCanBeEnabled,
|
||||||
|
settingSearchText,
|
||||||
|
} from './editors'
|
||||||
|
import { minecraftKeybindConflictKey } from './keybinds'
|
||||||
|
import {
|
||||||
|
formatGameSettingDescription,
|
||||||
|
formatGameSettingLabel,
|
||||||
|
gameSettingCategoryMessage,
|
||||||
|
} from './messages'
|
||||||
|
import GameSettingRow from './row.vue'
|
||||||
|
import { useGameSettingsEditor } from './use-editor'
|
||||||
|
import { useGameSettingLabels } from './use-labels'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
instanceId?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const router = useRouter()
|
||||||
|
const appSettingsModal = inject(appSettingsModalContextKey, null)
|
||||||
|
const instanceSettings = injectInstanceSettings(null)
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.title',
|
||||||
|
defaultMessage: 'Game settings',
|
||||||
|
},
|
||||||
|
syncTitle: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.sync-title',
|
||||||
|
defaultMessage: 'Sync game settings',
|
||||||
|
},
|
||||||
|
syncedOptionsDescription: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.sync-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Synced options are applied to every instance and override the values included with a modpack.',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.search',
|
||||||
|
defaultMessage: 'Search settings...',
|
||||||
|
},
|
||||||
|
enableAll: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.enable-all',
|
||||||
|
defaultMessage: 'Sync all',
|
||||||
|
},
|
||||||
|
disableAll: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.disable-all',
|
||||||
|
defaultMessage: 'Unsync all',
|
||||||
|
},
|
||||||
|
customEmpty: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.custom-empty',
|
||||||
|
defaultMessage:
|
||||||
|
'Settings added by mods will appear here after Modrinth finds them in one of your instances.',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.empty',
|
||||||
|
defaultMessage: 'No settings match your search or filter.',
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.loading',
|
||||||
|
defaultMessage: 'Loading settings...',
|
||||||
|
},
|
||||||
|
loadFailed: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.load-failed',
|
||||||
|
defaultMessage: 'We couldn’t load your game settings.',
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.retry',
|
||||||
|
defaultMessage: 'Retry',
|
||||||
|
},
|
||||||
|
saveSettings: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.save',
|
||||||
|
defaultMessage: 'Save settings',
|
||||||
|
},
|
||||||
|
unsavedChanges: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.unsaved-changes',
|
||||||
|
defaultMessage: 'You have unsaved changes.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||||
|
const activeCategoryId = ref('')
|
||||||
|
const search = ref('')
|
||||||
|
const opened = ref(false)
|
||||||
|
let allowClose = false
|
||||||
|
|
||||||
|
const {
|
||||||
|
draftState,
|
||||||
|
isLocalEditor,
|
||||||
|
isDirty,
|
||||||
|
hasBlockingDraft,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
saving,
|
||||||
|
load,
|
||||||
|
reset: resetEditor,
|
||||||
|
cancelChanges,
|
||||||
|
setSyncEnabled,
|
||||||
|
setCanonicalValue,
|
||||||
|
save,
|
||||||
|
saveAndWait,
|
||||||
|
} = useGameSettingsEditor(
|
||||||
|
() => props.instanceId,
|
||||||
|
() => emit('saved'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, Component> = {
|
||||||
|
skin_customization: ShirtIcon,
|
||||||
|
video: MonitorIcon,
|
||||||
|
video_settings: MonitorIcon,
|
||||||
|
language: LanguagesIcon,
|
||||||
|
music_and_sound: TagCategoryAudioIcon,
|
||||||
|
controls: TagCategoryGamepad2Icon,
|
||||||
|
chat: MessageIcon,
|
||||||
|
chat_settings: MessageIcon,
|
||||||
|
accessibility: EyeIcon,
|
||||||
|
custom: WrenchIcon,
|
||||||
|
custom_settings: WrenchIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeLabels = useGameSettingLabels(
|
||||||
|
opened,
|
||||||
|
() => props.instanceId,
|
||||||
|
() => draftState.value?.settings ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
|
function settingLabel(setting: EditableGameSetting) {
|
||||||
|
if (setting.kind === 'external') {
|
||||||
|
return (
|
||||||
|
localeLabels.value[setting.option_id]?.label ?? formatGameSettingLabel(formatMessage, setting)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return formatGameSettingLabel(formatMessage, setting)
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = computed<GameSettingCategory[]>(() => {
|
||||||
|
if (!draftState.value) return []
|
||||||
|
|
||||||
|
const settings = draftState.value.settings
|
||||||
|
const visible = draftState.value.categories.filter(
|
||||||
|
(category) =>
|
||||||
|
category.is_custom || settings.some((setting) => setting.category_id === category.id),
|
||||||
|
)
|
||||||
|
if (!visible.some((category) => category.is_custom || category.id === 'custom_settings')) {
|
||||||
|
visible.push({
|
||||||
|
id: 'custom_settings',
|
||||||
|
is_custom: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return visible
|
||||||
|
})
|
||||||
|
|
||||||
|
const categoryTabs = computed<TabbedModalTab[]>(() =>
|
||||||
|
categories.value.map((category) => ({
|
||||||
|
name: gameSettingCategoryMessage(category),
|
||||||
|
icon: categoryIcon(category),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeCategory = computed(() =>
|
||||||
|
categories.value.find((category) => category.id === activeCategoryId.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const categorySettings = computed(() => {
|
||||||
|
if (!draftState.value) return []
|
||||||
|
|
||||||
|
const query = search.value.trim().toLocaleLowerCase()
|
||||||
|
return draftState.value.settings.filter((setting) => {
|
||||||
|
if (!query && setting.category_id !== activeCategoryId.value) return false
|
||||||
|
if (
|
||||||
|
query &&
|
||||||
|
!settingSearchText(
|
||||||
|
setting,
|
||||||
|
settingLabel(setting),
|
||||||
|
setting.kind === 'external'
|
||||||
|
? (localeLabels.value[setting.option_id]?.source?.project?.title ??
|
||||||
|
localeLabels.value[setting.option_id]?.source?.file_name ??
|
||||||
|
'')
|
||||||
|
: formatGameSettingDescription(formatMessage, setting),
|
||||||
|
).includes(query)
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const keybindConflicts = computed(() => {
|
||||||
|
if (!draftState.value) return new Map<string, string[]>()
|
||||||
|
|
||||||
|
const bindings = new Map<string, EditableGameSetting[]>()
|
||||||
|
for (const setting of draftState.value.settings) {
|
||||||
|
if (!isKeybindSetting(setting)) continue
|
||||||
|
const key = minecraftKeybindConflictKey(setting.option_id, canonicalValueText(setting))
|
||||||
|
if (!key) continue
|
||||||
|
bindings.set(key, [...(bindings.get(key) ?? []), setting])
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = new Map<string, string[]>()
|
||||||
|
for (const settings of bindings.values()) {
|
||||||
|
if (settings.length < 2) continue
|
||||||
|
for (const setting of settings) {
|
||||||
|
conflicts.set(
|
||||||
|
setting.option_id,
|
||||||
|
settings
|
||||||
|
.filter((candidate) => candidate.option_id !== setting.option_id)
|
||||||
|
.map((candidate) => settingLabel(candidate)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conflicts
|
||||||
|
})
|
||||||
|
|
||||||
|
const enableCandidates = computed(() =>
|
||||||
|
categorySettings.value.filter((setting) => !setting.sync_enabled && settingCanBeEnabled(setting)),
|
||||||
|
)
|
||||||
|
const disableCandidates = computed(() =>
|
||||||
|
categorySettings.value.filter((setting) => setting.sync_enabled && !setting.controlled),
|
||||||
|
)
|
||||||
|
const toggleableCategorySettings = computed(() =>
|
||||||
|
categorySettings.value.filter((setting) => !setting.controlled),
|
||||||
|
)
|
||||||
|
const allCategorySettingsSynced = computed(
|
||||||
|
() =>
|
||||||
|
toggleableCategorySettings.value.length > 0 &&
|
||||||
|
toggleableCategorySettings.value.every((setting) => setting.sync_enabled),
|
||||||
|
)
|
||||||
|
const modalTitle = computed(() =>
|
||||||
|
formatMessage(isLocalEditor.value ? messages.title : messages.syncTitle),
|
||||||
|
)
|
||||||
|
|
||||||
|
function categoryIcon(category: GameSettingCategory): Component {
|
||||||
|
return categoryIcons[category.id] ?? SettingsIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeCategory(_fromIndex: number, toIndex: number): boolean {
|
||||||
|
const category = categories.value[toIndex]
|
||||||
|
if (!category) return false
|
||||||
|
activeCategoryId.value = category.id
|
||||||
|
search.value = ''
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
categories,
|
||||||
|
() => {
|
||||||
|
if (!categories.value.some((category) => category.id === activeCategoryId.value)) {
|
||||||
|
activeCategoryId.value = categories.value[0]?.id ?? 'custom_settings'
|
||||||
|
}
|
||||||
|
const index = categories.value.findIndex((category) => category.id === activeCategoryId.value)
|
||||||
|
if (index >= 0) modal.value?.setTab(index)
|
||||||
|
},
|
||||||
|
{ flush: 'sync' },
|
||||||
|
)
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
opened.value = true
|
||||||
|
allowClose = false
|
||||||
|
search.value = ''
|
||||||
|
void load()
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
opened.value = false
|
||||||
|
resetEditor()
|
||||||
|
allowClose = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeHide(): boolean {
|
||||||
|
if (allowClose || !isDirty.value) return true
|
||||||
|
void saveBeforeClose()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBeforeClose() {
|
||||||
|
const saved = await saveAndWait()
|
||||||
|
if (!saved) return
|
||||||
|
allowClose = true
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSource(location: RouteLocationRaw) {
|
||||||
|
if (isDirty.value || saving.value) return
|
||||||
|
if (appSettingsModal && !appSettingsModal.close()) return
|
||||||
|
allowClose = true
|
||||||
|
modal.value?.hide()
|
||||||
|
if (instanceSettings?.closeModal) {
|
||||||
|
instanceSettings.closeModal(() => void router.push(location))
|
||||||
|
} else {
|
||||||
|
await router.push(location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVisibleSync() {
|
||||||
|
const enabled = !allCategorySettingsSynced.value
|
||||||
|
const candidates = enabled ? enableCandidates.value : disableCandidates.value
|
||||||
|
setSyncEnabled(
|
||||||
|
candidates.map((setting) => setting.option_id),
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabbedModal
|
||||||
|
ref="modal"
|
||||||
|
:tabs="categoryTabs"
|
||||||
|
:header="modalTitle"
|
||||||
|
:before-hide="beforeHide"
|
||||||
|
:before-tab-change="changeCategory"
|
||||||
|
:hide-tab-selection="search.trim().length > 0"
|
||||||
|
:on-after-hide="reset"
|
||||||
|
:floating-action-bar-shown="isDirty"
|
||||||
|
max-width="min(1080px, calc(95vw - 2rem))"
|
||||||
|
width="min(1080px, calc(95vw - 2rem))"
|
||||||
|
>
|
||||||
|
<template #sidebar-header>
|
||||||
|
<div class="pb-4">
|
||||||
|
<StyledInput
|
||||||
|
v-model="search"
|
||||||
|
:icon="SearchIcon"
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="formatMessage(messages.search)"
|
||||||
|
:aria-label="formatMessage(messages.search)"
|
||||||
|
wrapper-class="w-full shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="flex min-h-full min-w-0 flex-col">
|
||||||
|
<div
|
||||||
|
v-if="!isLocalEditor && (loading || loadError || categorySettings.length > 0)"
|
||||||
|
class="mb-4 flex shrink-0 flex-wrap items-start justify-between gap-4 border-0 border-b border-solid border-surface-4 pb-4"
|
||||||
|
>
|
||||||
|
<p class="m-0 min-w-60 flex-1 text-[var(--color-text-default)]">
|
||||||
|
{{ formatMessage(messages.syncedOptionsDescription) }}
|
||||||
|
</p>
|
||||||
|
<div class="ml-auto flex w-48 justify-end">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
:disabled="
|
||||||
|
allCategorySettingsSynced
|
||||||
|
? disableCandidates.length === 0
|
||||||
|
: enableCandidates.length === 0
|
||||||
|
"
|
||||||
|
@click="toggleVisibleSync"
|
||||||
|
>
|
||||||
|
<LinkIcon v-if="allCategorySettingsSynced" aria-hidden="true" />
|
||||||
|
<UnlinkIcon v-else aria-hidden="true" />
|
||||||
|
{{
|
||||||
|
formatMessage(allCategorySettingsSynced ? messages.disableAll : messages.enableAll)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
role="status"
|
||||||
|
:aria-label="formatMessage(messages.loading)"
|
||||||
|
class="flex flex-1 flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="row in 5"
|
||||||
|
:key="row"
|
||||||
|
class="h-20 animate-pulse rounded-xl bg-surface-3"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="loadError"
|
||||||
|
class="flex min-h-40 flex-1 flex-col items-center justify-center gap-3 text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
<p class="m-0">{{ formatMessage(messages.loadFailed) }}</p>
|
||||||
|
<Button @click="load">
|
||||||
|
<RefreshCwIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.retry) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="min-h-0 flex-1">
|
||||||
|
<div
|
||||||
|
v-if="categorySettings.length === 0"
|
||||||
|
class="flex h-full min-h-40 items-center justify-center px-8 text-center text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
activeCategory?.is_custom && !search
|
||||||
|
? formatMessage(messages.customEmpty)
|
||||||
|
: formatMessage(messages.empty)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col gap-4">
|
||||||
|
<GameSettingRow
|
||||||
|
v-for="setting in categorySettings"
|
||||||
|
:key="setting.option_id"
|
||||||
|
:setting="setting"
|
||||||
|
:locale-label="localeLabels[setting.option_id]"
|
||||||
|
:keybind-conflicts="keybindConflicts.get(setting.option_id)"
|
||||||
|
:show-sync-toggle="!isLocalEditor"
|
||||||
|
:source-navigation-disabled="isDirty || saving"
|
||||||
|
@open-source="openSource"
|
||||||
|
@update:sync-enabled="setSyncEnabled([setting.option_id], $event)"
|
||||||
|
@update:canonical-value="setCanonicalValue(setting.option_id, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #floating-action-bar>
|
||||||
|
<FloatingActionBar v-if="isDirty" shown inline :aria-label="modalTitle">
|
||||||
|
<p class="m-0 text-sm font-semibold md:text-base">
|
||||||
|
{{ formatMessage(messages.unsavedChanges) }}
|
||||||
|
</p>
|
||||||
|
<div class="ml-auto flex gap-2">
|
||||||
|
<Button type="outlined" @click="cancelChanges">
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="brand"
|
||||||
|
:disabled="!isDirty || loading || saving || hasBlockingDraft"
|
||||||
|
:loading="saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
<SaveIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.saveSettings) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FloatingActionBar>
|
||||||
|
</template>
|
||||||
|
</TabbedModal>
|
||||||
|
</template>
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TriangleAlertIcon } from '@modrinth/assets'
|
||||||
|
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, onBeforeUnmount, ref, useId, watch } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
activateKeybindRecording,
|
||||||
|
deactivateKeybindRecording,
|
||||||
|
formatMinecraftKeybind,
|
||||||
|
minecraftKeyTokenFromKeyboardEvent,
|
||||||
|
minecraftMouseTokenFromButton,
|
||||||
|
} from './keybinds'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
settingLabel: string
|
||||||
|
conflicts?: string[]
|
||||||
|
mixed?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
conflicts: () => [],
|
||||||
|
mixed: false,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:model-value': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage, locale } = useVIntl()
|
||||||
|
const statusId = useId()
|
||||||
|
const conflictId = useId()
|
||||||
|
const recording = ref(false)
|
||||||
|
const statusMessage = ref('')
|
||||||
|
const controlElement = ref<HTMLElement | null>(null)
|
||||||
|
let suppressNextClick = false
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
listen: {
|
||||||
|
id: 'app.settings.game-options.keybind.listen',
|
||||||
|
defaultMessage: 'Press a key…',
|
||||||
|
},
|
||||||
|
choose: {
|
||||||
|
id: 'app.settings.game-options.keybind.choose',
|
||||||
|
defaultMessage: 'Choose a key',
|
||||||
|
},
|
||||||
|
mixed: {
|
||||||
|
id: 'app.settings.game-options.keybind.mixed',
|
||||||
|
defaultMessage: 'Different across instances',
|
||||||
|
},
|
||||||
|
change: {
|
||||||
|
id: 'app.settings.game-options.keybind.change',
|
||||||
|
defaultMessage: '{setting}: {binding}. Activate to change the binding.',
|
||||||
|
},
|
||||||
|
listeningStatus: {
|
||||||
|
id: 'app.settings.game-options.keybind.listening-status',
|
||||||
|
defaultMessage: 'Listening for a key or mouse button. Press Escape to clear this binding.',
|
||||||
|
},
|
||||||
|
recordingLabel: {
|
||||||
|
id: 'app.settings.game-options.keybind.recording-label',
|
||||||
|
defaultMessage:
|
||||||
|
'{setting}: listening for a key or mouse button. Press Escape to clear this binding.',
|
||||||
|
},
|
||||||
|
unsupportedStatus: {
|
||||||
|
id: 'app.settings.game-options.keybind.unsupported-status',
|
||||||
|
defaultMessage: 'That input cannot be used by Minecraft. Press another key.',
|
||||||
|
},
|
||||||
|
cancelledStatus: {
|
||||||
|
id: 'app.settings.game-options.keybind.cancelled-status',
|
||||||
|
defaultMessage: 'Key binding change cancelled.',
|
||||||
|
},
|
||||||
|
assignedStatus: {
|
||||||
|
id: 'app.settings.game-options.keybind.assigned-status',
|
||||||
|
defaultMessage: '{setting} is now bound to {binding}.',
|
||||||
|
},
|
||||||
|
conflict: {
|
||||||
|
id: 'app.settings.game-options.keybind.conflict-description',
|
||||||
|
defaultMessage: 'This is already used by {settings}.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform)
|
||||||
|
const bindingLabel = computed(() => {
|
||||||
|
if (props.mixed) return formatMessage(messages.mixed)
|
||||||
|
if (!props.modelValue) return formatMessage(messages.choose)
|
||||||
|
return formatMinecraftKeybind(formatMessage, props.modelValue, isMac)
|
||||||
|
})
|
||||||
|
const visibleLabel = computed(() =>
|
||||||
|
recording.value ? formatMessage(messages.listen) : bindingLabel.value,
|
||||||
|
)
|
||||||
|
const conflictSettings = computed(() =>
|
||||||
|
new Intl.ListFormat(locale.value, { style: 'long', type: 'conjunction' }).format(
|
||||||
|
props.conflicts.map((setting) => `‘${setting}’`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const conflictMessage = computed(() =>
|
||||||
|
props.conflicts.length
|
||||||
|
? formatMessage(messages.conflict, { settings: conflictSettings.value })
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
const accessibleLabel = computed(() =>
|
||||||
|
formatMessage(recording.value ? messages.recordingLabel : messages.change, {
|
||||||
|
setting: props.settingLabel,
|
||||||
|
binding: bindingLabel.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
if (props.disabled || recording.value) return
|
||||||
|
activateKeybindRecording(cancelRecording)
|
||||||
|
recording.value = true
|
||||||
|
statusMessage.value = formatMessage(messages.listeningStatus)
|
||||||
|
window.addEventListener('keydown', handleKeydown, true)
|
||||||
|
window.addEventListener('pointerdown', handlePointerDown, true)
|
||||||
|
window.addEventListener('blur', cancelRecording)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
recording.value = false
|
||||||
|
window.removeEventListener('keydown', handleKeydown, true)
|
||||||
|
window.removeEventListener('pointerdown', handlePointerDown, true)
|
||||||
|
window.removeEventListener('blur', cancelRecording)
|
||||||
|
deactivateKeybindRecording(cancelRecording)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRecording() {
|
||||||
|
if (!recording.value) return
|
||||||
|
stopRecording()
|
||||||
|
statusMessage.value = formatMessage(messages.cancelledStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assign(value: string) {
|
||||||
|
if (!recording.value) return
|
||||||
|
stopRecording()
|
||||||
|
emit('update:model-value', value)
|
||||||
|
statusMessage.value = formatMessage(messages.assignedStatus, {
|
||||||
|
setting: props.settingLabel,
|
||||||
|
binding: formatMinecraftKeybind(formatMessage, value, isMac),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event: MouseEvent) {
|
||||||
|
if (suppressNextClick) {
|
||||||
|
suppressNextClick = false
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!recording.value) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (event.code === 'Escape') {
|
||||||
|
assign('key.keyboard.unknown')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.repeat) return
|
||||||
|
|
||||||
|
const token = event.isComposing ? null : minecraftKeyTokenFromKeyboardEvent(event)
|
||||||
|
if (!token) {
|
||||||
|
statusMessage.value = formatMessage(messages.unsupportedStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assign(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (!recording.value) return
|
||||||
|
if (event.pointerType !== 'mouse') return
|
||||||
|
const otherKeybindControl =
|
||||||
|
event.target instanceof Element
|
||||||
|
? event.target.closest<HTMLElement>('[data-game-keybind-control]')
|
||||||
|
: null
|
||||||
|
if (otherKeybindControl && otherKeybindControl !== controlElement.value) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const token = minecraftMouseTokenFromButton(event.button)
|
||||||
|
if (!token) {
|
||||||
|
statusMessage.value = formatMessage(messages.unsupportedStatus)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suppressNextClick = event.button === 0 && otherKeybindControl === controlElement.value
|
||||||
|
assign(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
(disabled) => {
|
||||||
|
if (disabled) cancelRecording()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
onBeforeUnmount(cancelRecording)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full min-w-0 items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
ref="controlElement"
|
||||||
|
v-tooltip="!recording && conflictMessage ? conflictMessage : undefined"
|
||||||
|
data-game-keybind-control
|
||||||
|
class="flex min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
:type="recording || conflicts.length ? 'outlined' : 'base'"
|
||||||
|
:color="recording ? 'brand' : conflicts.length ? 'orange' : undefined"
|
||||||
|
size="md"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="accessibleLabel"
|
||||||
|
:aria-describedby="recording ? statusId : conflicts.length ? conflictId : undefined"
|
||||||
|
class="w-full overflow-hidden"
|
||||||
|
@click="handleClick"
|
||||||
|
@contextmenu.prevent.stop
|
||||||
|
@auxclick.prevent.stop
|
||||||
|
>
|
||||||
|
<TriangleAlertIcon v-if="!recording && conflicts.length" aria-hidden="true" />
|
||||||
|
<span class="min-w-0 truncate">{{ visibleLabel }}</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span :id="statusId" class="visually-hidden" role="status" aria-live="polite">
|
||||||
|
{{ statusMessage }}
|
||||||
|
</span>
|
||||||
|
<span v-if="conflicts.length" :id="conflictId" class="visually-hidden">
|
||||||
|
{{ conflictMessage }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+426
@@ -0,0 +1,426 @@
|
|||||||
|
import { defineMessages, type MessageDescriptor, type VIntlFormatters } from '@modrinth/ui'
|
||||||
|
|
||||||
|
type FormatMessage = VIntlFormatters['formatMessage']
|
||||||
|
|
||||||
|
type StopKeybindRecording = () => void
|
||||||
|
let stopActiveKeybindRecording: StopKeybindRecording | null = null
|
||||||
|
|
||||||
|
export function activateKeybindRecording(stop: StopKeybindRecording) {
|
||||||
|
if (stopActiveKeybindRecording === stop) return
|
||||||
|
const stopPrevious = stopActiveKeybindRecording
|
||||||
|
stopActiveKeybindRecording = stop
|
||||||
|
stopPrevious?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivateKeybindRecording(stop: StopKeybindRecording) {
|
||||||
|
if (stopActiveKeybindRecording === stop) {
|
||||||
|
stopActiveKeybindRecording = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyMessages = defineMessages({
|
||||||
|
unbound: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.unbound',
|
||||||
|
defaultMessage: 'Unbound',
|
||||||
|
},
|
||||||
|
escape: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.escape',
|
||||||
|
defaultMessage: 'Escape',
|
||||||
|
},
|
||||||
|
backspace: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.backspace',
|
||||||
|
defaultMessage: 'Backspace',
|
||||||
|
},
|
||||||
|
tab: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.tab',
|
||||||
|
defaultMessage: 'Tab',
|
||||||
|
},
|
||||||
|
enter: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.enter',
|
||||||
|
defaultMessage: 'Enter',
|
||||||
|
},
|
||||||
|
leftControl: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.left-control',
|
||||||
|
defaultMessage: 'Left Ctrl',
|
||||||
|
},
|
||||||
|
rightControl: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.right-control',
|
||||||
|
defaultMessage: 'Right Ctrl',
|
||||||
|
},
|
||||||
|
leftShift: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.left-shift',
|
||||||
|
defaultMessage: 'Left Shift',
|
||||||
|
},
|
||||||
|
rightShift: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.right-shift',
|
||||||
|
defaultMessage: 'Right Shift',
|
||||||
|
},
|
||||||
|
leftAlt: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.left-alt',
|
||||||
|
defaultMessage: 'Left Alt',
|
||||||
|
},
|
||||||
|
rightAlt: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.right-alt',
|
||||||
|
defaultMessage: 'Right Alt',
|
||||||
|
},
|
||||||
|
space: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.space',
|
||||||
|
defaultMessage: 'Space',
|
||||||
|
},
|
||||||
|
capsLock: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.caps-lock',
|
||||||
|
defaultMessage: 'Caps Lock',
|
||||||
|
},
|
||||||
|
numLock: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.num-lock',
|
||||||
|
defaultMessage: 'Num Lock',
|
||||||
|
},
|
||||||
|
scrollLock: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.scroll-lock',
|
||||||
|
defaultMessage: 'Scroll Lock',
|
||||||
|
},
|
||||||
|
printScreen: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.print-screen',
|
||||||
|
defaultMessage: 'Print Screen',
|
||||||
|
},
|
||||||
|
pause: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.pause',
|
||||||
|
defaultMessage: 'Pause',
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.home',
|
||||||
|
defaultMessage: 'Home',
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.end',
|
||||||
|
defaultMessage: 'End',
|
||||||
|
},
|
||||||
|
pageUp: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.page-up',
|
||||||
|
defaultMessage: 'Page Up',
|
||||||
|
},
|
||||||
|
pageDown: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.page-down',
|
||||||
|
defaultMessage: 'Page Down',
|
||||||
|
},
|
||||||
|
insert: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.insert',
|
||||||
|
defaultMessage: 'Insert',
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.delete',
|
||||||
|
defaultMessage: 'Delete',
|
||||||
|
},
|
||||||
|
arrowUp: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.arrow-up',
|
||||||
|
defaultMessage: 'Up Arrow',
|
||||||
|
},
|
||||||
|
arrowDown: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.arrow-down',
|
||||||
|
defaultMessage: 'Down Arrow',
|
||||||
|
},
|
||||||
|
arrowLeft: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.arrow-left',
|
||||||
|
defaultMessage: 'Left Arrow',
|
||||||
|
},
|
||||||
|
arrowRight: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.arrow-right',
|
||||||
|
defaultMessage: 'Right Arrow',
|
||||||
|
},
|
||||||
|
leftSuper: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.left-super',
|
||||||
|
defaultMessage: 'Left Super',
|
||||||
|
},
|
||||||
|
rightSuper: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.right-super',
|
||||||
|
defaultMessage: 'Right Super',
|
||||||
|
},
|
||||||
|
leftCommand: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.left-command',
|
||||||
|
defaultMessage: 'Left Command',
|
||||||
|
},
|
||||||
|
rightCommand: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.right-command',
|
||||||
|
defaultMessage: 'Right Command',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.menu',
|
||||||
|
defaultMessage: 'Menu',
|
||||||
|
},
|
||||||
|
keypadKey: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.keypad-key',
|
||||||
|
defaultMessage: 'Numpad {key}',
|
||||||
|
},
|
||||||
|
leftMouse: {
|
||||||
|
id: 'app.settings.game-options.keybind.mouse.left',
|
||||||
|
defaultMessage: 'Left Mouse',
|
||||||
|
},
|
||||||
|
rightMouse: {
|
||||||
|
id: 'app.settings.game-options.keybind.mouse.right',
|
||||||
|
defaultMessage: 'Right Mouse',
|
||||||
|
},
|
||||||
|
middleMouse: {
|
||||||
|
id: 'app.settings.game-options.keybind.mouse.middle',
|
||||||
|
defaultMessage: 'Middle Mouse',
|
||||||
|
},
|
||||||
|
mouseButton: {
|
||||||
|
id: 'app.settings.game-options.keybind.mouse.button',
|
||||||
|
defaultMessage: 'Mouse {button}',
|
||||||
|
},
|
||||||
|
scancode: {
|
||||||
|
id: 'app.settings.game-options.keybind.scancode',
|
||||||
|
defaultMessage: 'Scancode {code}',
|
||||||
|
},
|
||||||
|
unknownKey: {
|
||||||
|
id: 'app.settings.game-options.keybind.key.unknown',
|
||||||
|
defaultMessage: 'Unknown key',
|
||||||
|
},
|
||||||
|
controlModifier: {
|
||||||
|
id: 'app.settings.game-options.keybind.modifier.control',
|
||||||
|
defaultMessage: 'Ctrl',
|
||||||
|
},
|
||||||
|
shiftModifier: {
|
||||||
|
id: 'app.settings.game-options.keybind.modifier.shift',
|
||||||
|
defaultMessage: 'Shift',
|
||||||
|
},
|
||||||
|
altModifier: {
|
||||||
|
id: 'app.settings.game-options.keybind.modifier.alt',
|
||||||
|
defaultMessage: 'Alt',
|
||||||
|
},
|
||||||
|
commandModifier: {
|
||||||
|
id: 'app.settings.game-options.keybind.modifier.command',
|
||||||
|
defaultMessage: 'Command',
|
||||||
|
},
|
||||||
|
superModifier: {
|
||||||
|
id: 'app.settings.game-options.keybind.modifier.super',
|
||||||
|
defaultMessage: 'Super',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyboardCodeTokens: Record<string, string> = {
|
||||||
|
Escape: 'escape',
|
||||||
|
Minus: 'minus',
|
||||||
|
Equal: 'equal',
|
||||||
|
Backspace: 'backspace',
|
||||||
|
Tab: 'tab',
|
||||||
|
BracketLeft: 'left.bracket',
|
||||||
|
BracketRight: 'right.bracket',
|
||||||
|
Enter: 'enter',
|
||||||
|
ControlLeft: 'left.control',
|
||||||
|
Semicolon: 'semicolon',
|
||||||
|
Quote: 'apostrophe',
|
||||||
|
Backquote: 'grave.accent',
|
||||||
|
ShiftLeft: 'left.shift',
|
||||||
|
Backslash: 'backslash',
|
||||||
|
Comma: 'comma',
|
||||||
|
Period: 'period',
|
||||||
|
Slash: 'slash',
|
||||||
|
ShiftRight: 'right.shift',
|
||||||
|
AltLeft: 'left.alt',
|
||||||
|
Space: 'space',
|
||||||
|
CapsLock: 'caps.lock',
|
||||||
|
NumLock: 'num.lock',
|
||||||
|
ScrollLock: 'scroll.lock',
|
||||||
|
NumpadSubtract: 'keypad.subtract',
|
||||||
|
NumpadAdd: 'keypad.add',
|
||||||
|
NumpadDecimal: 'keypad.decimal',
|
||||||
|
NumpadComma: 'keypad.decimal',
|
||||||
|
NumpadEqual: 'keypad.equal',
|
||||||
|
NumpadEnter: 'keypad.enter',
|
||||||
|
ControlRight: 'right.control',
|
||||||
|
NumpadDivide: 'keypad.divide',
|
||||||
|
NumpadMultiply: 'keypad.multiply',
|
||||||
|
PrintScreen: 'print.screen',
|
||||||
|
AltRight: 'right.alt',
|
||||||
|
Pause: 'pause',
|
||||||
|
Home: 'home',
|
||||||
|
ArrowUp: 'up',
|
||||||
|
PageUp: 'page.up',
|
||||||
|
ArrowLeft: 'left',
|
||||||
|
ArrowRight: 'right',
|
||||||
|
End: 'end',
|
||||||
|
ArrowDown: 'down',
|
||||||
|
PageDown: 'page.down',
|
||||||
|
Insert: 'insert',
|
||||||
|
Delete: 'delete',
|
||||||
|
MetaLeft: 'left.win',
|
||||||
|
MetaRight: 'right.win',
|
||||||
|
ContextMenu: 'menu',
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboardLabels: Record<string, MessageDescriptor | string> = {
|
||||||
|
escape: keyMessages.escape,
|
||||||
|
minus: '-',
|
||||||
|
equal: '=',
|
||||||
|
backspace: keyMessages.backspace,
|
||||||
|
tab: keyMessages.tab,
|
||||||
|
'left.bracket': '[',
|
||||||
|
'right.bracket': ']',
|
||||||
|
enter: keyMessages.enter,
|
||||||
|
'left.control': keyMessages.leftControl,
|
||||||
|
semicolon: ';',
|
||||||
|
apostrophe: "'",
|
||||||
|
'grave.accent': '`',
|
||||||
|
'left.shift': keyMessages.leftShift,
|
||||||
|
backslash: '\\',
|
||||||
|
comma: ',',
|
||||||
|
period: '.',
|
||||||
|
slash: '/',
|
||||||
|
'right.shift': keyMessages.rightShift,
|
||||||
|
'left.alt': keyMessages.leftAlt,
|
||||||
|
space: keyMessages.space,
|
||||||
|
'caps.lock': keyMessages.capsLock,
|
||||||
|
'num.lock': keyMessages.numLock,
|
||||||
|
'scroll.lock': keyMessages.scrollLock,
|
||||||
|
'keypad.subtract': '-',
|
||||||
|
'keypad.add': '+',
|
||||||
|
'keypad.decimal': '.',
|
||||||
|
'keypad.equal': '=',
|
||||||
|
'keypad.enter': keyMessages.enter,
|
||||||
|
'right.control': keyMessages.rightControl,
|
||||||
|
'keypad.divide': '/',
|
||||||
|
'keypad.multiply': '*',
|
||||||
|
'print.screen': keyMessages.printScreen,
|
||||||
|
'right.alt': keyMessages.rightAlt,
|
||||||
|
pause: keyMessages.pause,
|
||||||
|
home: keyMessages.home,
|
||||||
|
up: keyMessages.arrowUp,
|
||||||
|
'page.up': keyMessages.pageUp,
|
||||||
|
left: keyMessages.arrowLeft,
|
||||||
|
right: keyMessages.arrowRight,
|
||||||
|
end: keyMessages.end,
|
||||||
|
down: keyMessages.arrowDown,
|
||||||
|
'page.down': keyMessages.pageDown,
|
||||||
|
insert: keyMessages.insert,
|
||||||
|
delete: keyMessages.delete,
|
||||||
|
menu: keyMessages.menu,
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitKeybind(value: string): [key: string, modifier?: string] {
|
||||||
|
const separator = value.indexOf(':')
|
||||||
|
return separator === -1 ? [value] : [value.slice(0, separator), value.slice(separator + 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardIdentifierFromCode(code: string): string | null {
|
||||||
|
const letter = /^Key([A-Z])$/.exec(code)
|
||||||
|
if (letter) return letter[1].toLowerCase()
|
||||||
|
|
||||||
|
const digit = /^Digit([0-9])$/.exec(code)
|
||||||
|
if (digit) return digit[1]
|
||||||
|
|
||||||
|
const functionKey = /^F([1-9]|1[0-9]|2[0-5])$/.exec(code)
|
||||||
|
if (functionKey) return `f${functionKey[1]}`
|
||||||
|
|
||||||
|
const keypadDigit = /^Numpad([0-9])$/.exec(code)
|
||||||
|
if (keypadDigit) return `keypad.${keypadDigit[1]}`
|
||||||
|
|
||||||
|
return keyboardCodeTokens[code] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minecraftKeyTokenFromKeyboardEvent(event: KeyboardEvent): string | null {
|
||||||
|
const identifier = keyboardIdentifierFromCode(event.code)
|
||||||
|
return identifier ? `key.keyboard.${identifier}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minecraftMouseTokenFromButton(button: number): string | null {
|
||||||
|
if (!Number.isInteger(button) || button < 0 || button > 15) return null
|
||||||
|
if (button === 0) return 'key.mouse.left'
|
||||||
|
if (button === 1) return 'key.mouse.middle'
|
||||||
|
if (button === 2) return 'key.mouse.right'
|
||||||
|
return `key.mouse.${button + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKeyboardLabel(
|
||||||
|
formatMessage: FormatMessage,
|
||||||
|
identifier: string,
|
||||||
|
isMac: boolean,
|
||||||
|
): string {
|
||||||
|
if (identifier === 'unknown') return formatMessage(keyMessages.unbound)
|
||||||
|
if (/^[a-z]$/.test(identifier)) return identifier.toUpperCase()
|
||||||
|
if (/^[0-9]$/.test(identifier)) return identifier
|
||||||
|
if (/^f([1-9]|1[0-9]|2[0-5])$/.test(identifier)) return identifier.toUpperCase()
|
||||||
|
|
||||||
|
const keypad = /^keypad\.(.+)$/.exec(identifier)
|
||||||
|
if (keypad) {
|
||||||
|
const label = keyboardLabels[identifier]
|
||||||
|
const key = typeof label === 'string' ? label : label ? formatMessage(label) : keypad[1]
|
||||||
|
return formatMessage(keyMessages.keypadKey, { key })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identifier === 'left.win') {
|
||||||
|
return formatMessage(isMac ? keyMessages.leftCommand : keyMessages.leftSuper)
|
||||||
|
}
|
||||||
|
if (identifier === 'right.win') {
|
||||||
|
return formatMessage(isMac ? keyMessages.rightCommand : keyMessages.rightSuper)
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = keyboardLabels[identifier]
|
||||||
|
if (typeof label === 'string') return label
|
||||||
|
if (label) return formatMessage(label)
|
||||||
|
return identifier
|
||||||
|
.split('.')
|
||||||
|
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModifier(formatMessage: FormatMessage, modifier: string, isMac: boolean): string {
|
||||||
|
switch (modifier.toUpperCase()) {
|
||||||
|
case 'CONTROL':
|
||||||
|
case 'CTRL':
|
||||||
|
return formatMessage(keyMessages.controlModifier)
|
||||||
|
case 'SHIFT':
|
||||||
|
return formatMessage(keyMessages.shiftModifier)
|
||||||
|
case 'ALT':
|
||||||
|
return formatMessage(keyMessages.altModifier)
|
||||||
|
case 'META':
|
||||||
|
case 'SUPER':
|
||||||
|
return formatMessage(isMac ? keyMessages.commandModifier : keyMessages.superModifier)
|
||||||
|
default:
|
||||||
|
return modifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMinecraftKeybind(
|
||||||
|
formatMessage: FormatMessage,
|
||||||
|
value: string,
|
||||||
|
isMac: boolean,
|
||||||
|
): string {
|
||||||
|
const [key, modifier] = splitKeybind(value)
|
||||||
|
let label: string
|
||||||
|
|
||||||
|
if (key.startsWith('key.keyboard.')) {
|
||||||
|
label = formatKeyboardLabel(formatMessage, key.slice('key.keyboard.'.length), isMac)
|
||||||
|
} else if (key === 'key.mouse.left') {
|
||||||
|
label = formatMessage(keyMessages.leftMouse)
|
||||||
|
} else if (key === 'key.mouse.right') {
|
||||||
|
label = formatMessage(keyMessages.rightMouse)
|
||||||
|
} else if (key === 'key.mouse.middle') {
|
||||||
|
label = formatMessage(keyMessages.middleMouse)
|
||||||
|
} else if (key.startsWith('key.mouse.')) {
|
||||||
|
label = formatMessage(keyMessages.mouseButton, {
|
||||||
|
button: key.slice('key.mouse.'.length),
|
||||||
|
})
|
||||||
|
} else if (key.startsWith('scancode.')) {
|
||||||
|
label = formatMessage(keyMessages.scancode, { code: key.slice('scancode.'.length) })
|
||||||
|
} else {
|
||||||
|
label = formatMessage(keyMessages.unknownKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modifier || key === 'key.keyboard.unknown') return label
|
||||||
|
const modifiers = modifier
|
||||||
|
.split(/[+:]/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => formatModifier(formatMessage, part, isMac))
|
||||||
|
return [...modifiers, label].join(' + ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minecraftKeybindConflictKey(optionId: string, value: string): string | null {
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (!normalized || normalized === 'key.keyboard.unknown') return null
|
||||||
|
|
||||||
|
if (optionId === 'key.debug.modifier') return null
|
||||||
|
if (optionId.startsWith('key.debug.') && optionId !== 'key.debug.overlay') {
|
||||||
|
return `debug:${normalized}`
|
||||||
|
}
|
||||||
|
return `direct:${normalized}`
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
import type { ComboboxOption } from '@modrinth/ui'
|
||||||
|
|
||||||
|
/** Java Edition language names and regions from https://github.com/misode/mcmeta/blob/assets/pack.mcmeta. */
|
||||||
|
const languageNames: Record<string, string> = {
|
||||||
|
en_us: 'English (US)',
|
||||||
|
af_za: 'Afrikaans (Suid-Afrika)',
|
||||||
|
esan: 'Andalûh (Andaluçía)',
|
||||||
|
enp: 'Anglish (Oned Riches)',
|
||||||
|
ast_es: 'Asturianu (Asturies)',
|
||||||
|
az_az: 'Azərbaycanca (Azərbaycan)',
|
||||||
|
id_id: 'Bahasa Indonesia (Indonesia)',
|
||||||
|
ms_my: 'Bahasa Melayu (Malaysia)',
|
||||||
|
tzo_mx: "Bats'i k'op (Jobel)",
|
||||||
|
qid: 'Bhs. Indonesia edjaän lama (Indonesia tempo doeloe)',
|
||||||
|
be_latn: 'Biełaruskaja (Biełaruś)',
|
||||||
|
bar: 'Boarisch (Bayern)',
|
||||||
|
bs_ba: 'Bosanski (Bosna i Hercegovina)',
|
||||||
|
brb: 'Braobans (Braobant)',
|
||||||
|
br_fr: 'Brezhoneg (Breizh)',
|
||||||
|
ca_es: 'Català (Catalunya)',
|
||||||
|
val_es: 'Català (Valencià) (País Valencià)',
|
||||||
|
cy_gb: 'Cymraeg (Cymru)',
|
||||||
|
qcb_es: 'Cántabru/Montañés (Cantabria)',
|
||||||
|
da_dk: 'Dansk (Danmark)',
|
||||||
|
se_no: 'Davvisámegiella (Sápmi)',
|
||||||
|
de_at: 'Deitsch (Österreich)',
|
||||||
|
de_de: 'Deutsch (Deutschland)',
|
||||||
|
et_ee: 'Eesti keel (Eesti)',
|
||||||
|
en_au: 'English (Australia)',
|
||||||
|
en_ca: 'English (Canada)',
|
||||||
|
en_nz: 'English (New Zealand)',
|
||||||
|
en_gb: 'English (United Kingdom)',
|
||||||
|
es_ar: 'Español (Argentina)',
|
||||||
|
es_cl: 'Español (Chile)',
|
||||||
|
es_ec: 'Español (Ecuador)',
|
||||||
|
es_es: 'Español (España)',
|
||||||
|
es_mx: 'Español (México)',
|
||||||
|
es_uy: 'Español (Uruguay)',
|
||||||
|
es_ve: 'Español (Venezuela)',
|
||||||
|
eo_uy: 'Esperanto (Esperantujo)',
|
||||||
|
eu_es: 'Euskara (Euskal Herria)',
|
||||||
|
fil_ph: 'Filipino (Pilipinas)',
|
||||||
|
fr_ca: 'Français (Canada)',
|
||||||
|
fr_fr: 'Français (France)',
|
||||||
|
fr_ch: 'Français (Suisse)',
|
||||||
|
fy_nl: 'Frysk (Fryslân)',
|
||||||
|
fra_de: 'Fränggisch (Franggn)',
|
||||||
|
fur_it: 'Furlan (Friûl)',
|
||||||
|
fo_fo: 'Føroyskt (Føroyar)',
|
||||||
|
ga_ie: 'Gaeilge (Éire)',
|
||||||
|
gl_es: 'Galego (Galicia / Galiza)',
|
||||||
|
go_fr: 'Galo (Bertègn)',
|
||||||
|
gd_gb: 'Gàidhlig (Alba)',
|
||||||
|
hr_hr: 'Hrvatski (Hrvatska)',
|
||||||
|
hn_no: 'Høgnorsk (Norig)',
|
||||||
|
io_en: 'Ido (Idia)',
|
||||||
|
ig_ng: 'Igbo (Naigeria)',
|
||||||
|
it_it: 'Italiano (Italia)',
|
||||||
|
kw_gb: 'Kernewek (Kernow)',
|
||||||
|
ksh: 'Kölsch/Ripoarisch (Rhingland)',
|
||||||
|
lol_us: 'LOLCAT (Kingdom of Cats)',
|
||||||
|
la_la: 'Latina (Latium)',
|
||||||
|
lv_lv: 'Latviešu (Latvija)',
|
||||||
|
lt_lt: 'Lietuvių (Lietuva)',
|
||||||
|
li_li: 'Limburgs (Limburg)',
|
||||||
|
lmo: 'Lombard (Lombardia)',
|
||||||
|
lb_lu: 'Lëtzebuergesch (Lëtzebuerg)',
|
||||||
|
hu_hu: 'Magyar (Magyarország)',
|
||||||
|
mt_mt: 'Malti (Malta)',
|
||||||
|
isv: 'Medžuslovjansky (Slovjanščina)',
|
||||||
|
nah: 'Mēxikatlahtōlli (Mēxiko)',
|
||||||
|
nl_nl: 'Nederlands (Nederland)',
|
||||||
|
pls: 'Ngiiwà (Ndanìꞌngà)',
|
||||||
|
no_no: 'Norsk bokmål (Norge)',
|
||||||
|
nn_no: 'Norsk nynorsk (Noreg)',
|
||||||
|
uz_uz: "O'zbekcha (O'zbekiston)",
|
||||||
|
oc_fr: 'Occitan (Occitània)',
|
||||||
|
en_pt: 'Pirate Speak (The Seven Seas)',
|
||||||
|
nds_de: 'Plattdüütsch (Düütschland)',
|
||||||
|
pl_pl: 'Polski (Polska)',
|
||||||
|
pt_br: 'Português (Brasil)',
|
||||||
|
pt_pt: 'Português (Portugal)',
|
||||||
|
qya_aa: 'Quenya (Arda)',
|
||||||
|
ro_ro: 'Română (România)',
|
||||||
|
de_ch: 'Schwiizerdütsch (Schwiiz)',
|
||||||
|
enws: 'Shakespearean English (Kingdom of England)',
|
||||||
|
sq_al: 'Shqip (Shqipëri)',
|
||||||
|
sk_sk: 'Slovenčina (Slovensko)',
|
||||||
|
sl_si: 'Slovenščina (Slovenija)',
|
||||||
|
so_so: 'Soomaali (Soomaaliya)',
|
||||||
|
sr_cs: 'Srpski (Srbija)',
|
||||||
|
fi_fi: 'Suomi (Suomi)',
|
||||||
|
sv_se: 'Svenska (Sverige)',
|
||||||
|
sxu: 'Säggs’sch (Saggsn)',
|
||||||
|
tl_ph: 'Tagalog (Pilipinas)',
|
||||||
|
vi_vn: 'Tiếng Việt (Việt Nam)',
|
||||||
|
tr_tr: 'Türkçe (Türkiye)',
|
||||||
|
vp_vl: 'Viossa (Vilant)',
|
||||||
|
nl_be: 'Vlaams (België)',
|
||||||
|
vec_it: 'Vèneto (Veneto)',
|
||||||
|
vro: 'Võro (Eesti)',
|
||||||
|
yo_ng: 'Yorùbá (Nàìjíríà)',
|
||||||
|
jbo_en: "la .lojban. (la jbogu'e)",
|
||||||
|
tlh_aa: "tlhIngan Hol (tlhIngan wo')",
|
||||||
|
tok: 'toki pona (kulupu pona)',
|
||||||
|
is_is: 'Íslenska (Ísland)',
|
||||||
|
ovd: 'Övdalska (Swerre)',
|
||||||
|
cs_cz: 'Čeština (Česko)',
|
||||||
|
szl: 'Ślōnski (Gōrny Ślōnsk)',
|
||||||
|
en_ud: 'ɥsᴉꞁᵷuƎ (uʍoᗡ ǝpᴉsd∩)',
|
||||||
|
haw_us: 'ʻŌlelo Hawaiʻi (Hawaiʻi)',
|
||||||
|
el_gr: 'Ελληνικά (Ελλάδα)',
|
||||||
|
ba_ru: 'Башҡортса (Башҡортостан, Рәсәй)',
|
||||||
|
be_by: 'Беларуская (Беларусь)',
|
||||||
|
bg_bg: 'Български (България)',
|
||||||
|
hal_ua: 'Галицка (Галичина, Вкраїна)',
|
||||||
|
ky_kg: 'Кыргызча (Кыргызстан)',
|
||||||
|
mk_mk: 'Македонски (Северна Македонија)',
|
||||||
|
mn_mn: 'Монгол (Монгол Улс)',
|
||||||
|
ry_ua: 'Руснацькый (Пудкарпатя, Украина)',
|
||||||
|
ru_ru: 'Русский (Россия)',
|
||||||
|
rpr: 'Русскій дореформенный (Россійская имперія)',
|
||||||
|
sah_sah: 'Сахалыы (Cаха Сирэ)',
|
||||||
|
sr_sp: 'Српски (Србија)',
|
||||||
|
tt_ru: 'Татарча (Татарстан, Рәсәй)',
|
||||||
|
uk_ua: 'Українська (Україна)',
|
||||||
|
cv_cu: 'Чӑвашла (Чӑваш Ен, Раҫҫей)',
|
||||||
|
kk_kz: 'Қазақша (Қазақстан)',
|
||||||
|
hy_am: 'Հայերեն (Հայաստան)',
|
||||||
|
yi_de: 'ייִדיש (אשכנזיש יידן)',
|
||||||
|
he_il: 'עברית (ישראל)',
|
||||||
|
ar_sa: 'العربية (العالم العربي)',
|
||||||
|
zlm_arab: 'بهاس ملايو (مليسيا)',
|
||||||
|
fa_ir: 'فارسی (ايران)',
|
||||||
|
hi_in: 'हिंदी (भारत)',
|
||||||
|
ta_in: 'தமிழ் (இந்தியா)',
|
||||||
|
kn_in: 'ಕನ್ನಡ (ಭಾರತ)',
|
||||||
|
th_th: 'ไทย (ประเทศไทย)',
|
||||||
|
lo_la: 'ລາວ (ປະເທດລາວ)',
|
||||||
|
ka_ge: 'ქართული (საქართველო)',
|
||||||
|
lzh: '文言 (華夏)',
|
||||||
|
ja_jp: '日本語 (日本)',
|
||||||
|
zh_cn: '简体中文 (中国大陆)',
|
||||||
|
zh_tw: '繁體中文 (台灣)',
|
||||||
|
zh_hk: '繁體中文 (香港特別行政區)',
|
||||||
|
ko_kr: '한국어 (대한민국)',
|
||||||
|
got_de: '𐌲𐌿𐍄𐍂𐌰𐌶𐌳𐌰 (𐌲𐌿𐍄𐌸𐌹𐌿𐌳𐌰)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const minecraftLanguageOptions: ComboboxOption<string>[] = Object.entries(languageNames)
|
||||||
|
.map(([value, label]) => ({ value, label, searchTerms: [value] }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
+1428
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,384 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LinkIcon, UnknownIcon, UnlinkIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Combobox,
|
||||||
|
type ComboboxOption,
|
||||||
|
defineMessages,
|
||||||
|
IconButton,
|
||||||
|
Slider,
|
||||||
|
StyledInput,
|
||||||
|
truncatedTooltip,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { type RouteLocationRaw, RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EditableGameSetting,
|
||||||
|
GameOptionCanonicalValue,
|
||||||
|
GameSettingLocaleLabel,
|
||||||
|
} from '@/helpers/game-options'
|
||||||
|
|
||||||
|
import GameSettingBooleanControl from './boolean-control.vue'
|
||||||
|
import {
|
||||||
|
canonicalBooleanValue,
|
||||||
|
canonicalValueFromInput,
|
||||||
|
canonicalValueText,
|
||||||
|
gameSettingNumberScale,
|
||||||
|
isKeybindSetting,
|
||||||
|
settingCanBeEnabled,
|
||||||
|
} from './editors'
|
||||||
|
import GameKeybindInput from './keybind-input.vue'
|
||||||
|
import { minecraftLanguageOptions } from './languages'
|
||||||
|
import {
|
||||||
|
formatGameSettingChoice,
|
||||||
|
formatGameSettingDescription,
|
||||||
|
formatGameSettingLabel,
|
||||||
|
formatGameSettingValidation,
|
||||||
|
presentationMessages,
|
||||||
|
} from './messages'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
setting: EditableGameSetting
|
||||||
|
localeLabel?: GameSettingLocaleLabel
|
||||||
|
keybindConflicts?: string[]
|
||||||
|
disabled?: boolean
|
||||||
|
showSyncToggle?: boolean
|
||||||
|
sourceNavigationDisabled?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
keybindConflicts: () => [],
|
||||||
|
disabled: false,
|
||||||
|
showSyncToggle: true,
|
||||||
|
sourceNavigationDisabled: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:sync-enabled': [enabled: boolean]
|
||||||
|
'update:canonical-value': [value: GameOptionCanonicalValue | null]
|
||||||
|
'open-source': [location: RouteLocationRaw]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const settingLabelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
on: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.value-on',
|
||||||
|
defaultMessage: 'On',
|
||||||
|
},
|
||||||
|
off: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.value-off',
|
||||||
|
defaultMessage: 'Off',
|
||||||
|
},
|
||||||
|
mixed: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.value-mixed',
|
||||||
|
defaultMessage: 'Different across instances',
|
||||||
|
},
|
||||||
|
unset: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.value-unset',
|
||||||
|
defaultMessage: 'Choose a value',
|
||||||
|
},
|
||||||
|
syncSetting: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.sync-setting',
|
||||||
|
defaultMessage: 'Sync {setting} across instances',
|
||||||
|
},
|
||||||
|
unsyncSetting: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.unsync-setting',
|
||||||
|
defaultMessage: 'Stop syncing {setting} across instances',
|
||||||
|
},
|
||||||
|
syncedAcrossInstances: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.synced-across-instances',
|
||||||
|
defaultMessage: 'Synced across instances',
|
||||||
|
},
|
||||||
|
notBeingSynced: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.not-being-synced',
|
||||||
|
defaultMessage: 'Not being synced',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingLabel = computed(() => {
|
||||||
|
if (props.setting.kind === 'external') {
|
||||||
|
return props.localeLabel?.label ?? formatGameSettingLabel(formatMessage, props.setting)
|
||||||
|
}
|
||||||
|
return formatGameSettingLabel(formatMessage, props.setting)
|
||||||
|
})
|
||||||
|
const settingDescription = computed(() =>
|
||||||
|
formatGameSettingDescription(formatMessage, props.setting),
|
||||||
|
)
|
||||||
|
const settingSource = computed(() =>
|
||||||
|
props.setting.kind === 'external' ? props.localeLabel?.source : undefined,
|
||||||
|
)
|
||||||
|
const sourceLocation = computed<RouteLocationRaw>(() => {
|
||||||
|
const source = settingSource.value
|
||||||
|
if (source?.project) {
|
||||||
|
return { path: `/project/${source.project.id}` }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: 'InstanceContent',
|
||||||
|
params: { id: source?.instance_id },
|
||||||
|
query: { highlight: source?.file_path },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const valueText = computed(() => canonicalValueText(props.setting))
|
||||||
|
const languageOptions = computed<ComboboxOption<string>[]>(() => {
|
||||||
|
const value = valueText.value
|
||||||
|
if (value && !minecraftLanguageOptions.some((option) => option.value === value)) {
|
||||||
|
return [{ value, label: value }, ...minecraftLanguageOptions]
|
||||||
|
}
|
||||||
|
return minecraftLanguageOptions
|
||||||
|
})
|
||||||
|
const enumOptions = computed<ComboboxOption<string>[]>(() =>
|
||||||
|
(props.setting.editor.choices ?? []).map((choice) => ({
|
||||||
|
value: choice.value,
|
||||||
|
label:
|
||||||
|
props.setting.kind === 'external'
|
||||||
|
? (props.localeLabel?.choices[choice.value] ?? choice.value)
|
||||||
|
: formatGameSettingChoice(formatMessage, props.setting.option_id, choice.value),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const isNumber = computed(
|
||||||
|
() => props.setting.editor.type === 'integer' || props.setting.editor.type === 'decimal',
|
||||||
|
)
|
||||||
|
const isSlider = computed(
|
||||||
|
() => isNumber.value && props.setting.editor.min != null && props.setting.editor.max != null,
|
||||||
|
)
|
||||||
|
const booleanValue = computed(() => canonicalBooleanValue(props.setting))
|
||||||
|
const numberScale = computed(() => gameSettingNumberScale(props.setting))
|
||||||
|
const inputMin = computed(() =>
|
||||||
|
props.setting.editor.min === null || props.setting.editor.min === undefined
|
||||||
|
? undefined
|
||||||
|
: props.setting.editor.min * numberScale.value,
|
||||||
|
)
|
||||||
|
const inputMax = computed(() =>
|
||||||
|
props.setting.editor.max === null || props.setting.editor.max === undefined
|
||||||
|
? undefined
|
||||||
|
: props.setting.editor.max * numberScale.value,
|
||||||
|
)
|
||||||
|
const inputStep = computed(() =>
|
||||||
|
props.setting.editor.step === null || props.setting.editor.step === undefined
|
||||||
|
? undefined
|
||||||
|
: props.setting.editor.step * numberScale.value,
|
||||||
|
)
|
||||||
|
const sliderValue = computed(() => {
|
||||||
|
if (valueText.value === '') return null
|
||||||
|
const value = Number(valueText.value)
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
})
|
||||||
|
const inputValue = computed(() =>
|
||||||
|
isNumber.value ? (sliderValue.value ?? undefined) : valueText.value,
|
||||||
|
)
|
||||||
|
const editorDisabled = computed(
|
||||||
|
() =>
|
||||||
|
props.disabled ||
|
||||||
|
props.setting.controlled ||
|
||||||
|
(props.showSyncToggle && !props.setting.sync_enabled),
|
||||||
|
)
|
||||||
|
const syncToggleDisabled = computed(
|
||||||
|
() =>
|
||||||
|
props.disabled ||
|
||||||
|
props.setting.controlled ||
|
||||||
|
(!props.setting.sync_enabled && !settingCanBeEnabled(props.setting)),
|
||||||
|
)
|
||||||
|
const placeholder = computed(() => {
|
||||||
|
if (props.setting.value_state === 'mixed') return formatMessage(messages.mixed)
|
||||||
|
if (props.setting.value_state === 'unset') return formatMessage(messages.unset)
|
||||||
|
if (props.setting.editor.type === 'external_raw') {
|
||||||
|
return formatMessage(presentationMessages.customValuePlaceholder)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const validationMessage = computed(() =>
|
||||||
|
formatGameSettingValidation(formatMessage, props.setting.validation_error),
|
||||||
|
)
|
||||||
|
const syncDisabledReason = computed(() => {
|
||||||
|
if (validationMessage.value) return validationMessage.value
|
||||||
|
if (props.setting.value_state === 'invalid') {
|
||||||
|
return formatMessage(presentationMessages.validationInvalidValue)
|
||||||
|
}
|
||||||
|
if (props.setting.value_state === 'mixed' || props.setting.value_state === 'unset') {
|
||||||
|
return formatMessage(presentationMessages.validationMissingValue)
|
||||||
|
}
|
||||||
|
if (props.setting.controlled) {
|
||||||
|
return formatMessage(presentationMessages.bucketLauncherControlled, { count: 1 })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.setting.compatibility.total_participating > 0 &&
|
||||||
|
props.setting.compatibility.will_receive === 0
|
||||||
|
) {
|
||||||
|
return formatMessage(presentationMessages.compatibilityNone)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const syncActionLabel = computed(() =>
|
||||||
|
formatMessage(props.setting.sync_enabled ? messages.unsyncSetting : messages.syncSetting, {
|
||||||
|
setting: settingLabel.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const syncStatusTooltip = computed(() =>
|
||||||
|
formatMessage(
|
||||||
|
props.setting.sync_enabled ? messages.syncedAcrossInstances : messages.notBeingSynced,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function updateValue(value: string | number | boolean | undefined) {
|
||||||
|
emit('update:canonical-value', canonicalValueFromInput(props.setting, value))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="grid min-h-[54px] min-w-0 items-center gap-2"
|
||||||
|
:class="
|
||||||
|
isSlider
|
||||||
|
? showSyncToggle
|
||||||
|
? 'grid-cols-[minmax(10rem,0.65fr)_minmax(0,1.35fr)_2.25rem]'
|
||||||
|
: 'grid-cols-[minmax(10rem,0.65fr)_minmax(0,1.35fr)]'
|
||||||
|
: showSyncToggle
|
||||||
|
? 'grid-cols-[minmax(0,1fr)_12rem_2.25rem]'
|
||||||
|
: 'grid-cols-[minmax(0,1fr)_12rem]'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="min-w-0 pr-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3
|
||||||
|
ref="settingLabelRef"
|
||||||
|
v-tooltip="truncatedTooltip(settingLabelRef, settingLabel)"
|
||||||
|
class="m-0 truncate text-lg font-semibold text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
{{ settingLabel }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
v-if="validationMessage"
|
||||||
|
v-tooltip="validationMessage"
|
||||||
|
tabindex="0"
|
||||||
|
role="img"
|
||||||
|
class="flex shrink-0 rounded-md text-orange outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||||
|
>
|
||||||
|
<UnknownIcon class="size-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<RouterLink v-if="settingSource" v-slot="{ href }" :to="sourceLocation" custom>
|
||||||
|
<a
|
||||||
|
:href="sourceNavigationDisabled ? undefined : href"
|
||||||
|
:aria-disabled="sourceNavigationDisabled || undefined"
|
||||||
|
class="mt-2 inline-flex max-w-full items-center gap-1.5 text-[var(--color-text-default)]"
|
||||||
|
:class="sourceNavigationDisabled ? 'cursor-default' : 'hover:underline'"
|
||||||
|
@click.prevent="!sourceNavigationDisabled && emit('open-source', sourceLocation)"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
v-if="settingSource.project"
|
||||||
|
:src="settingSource.project.icon_url"
|
||||||
|
size="1.25rem"
|
||||||
|
no-shadow
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{
|
||||||
|
settingSource.project?.title ?? settingSource.file_name
|
||||||
|
}}</span>
|
||||||
|
</a>
|
||||||
|
</RouterLink>
|
||||||
|
<p v-else-if="settingDescription" class="m-0 mt-0.5 text-[var(--color-text-default)]">
|
||||||
|
{{ settingDescription }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 items-center justify-end">
|
||||||
|
<Slider
|
||||||
|
v-if="isSlider"
|
||||||
|
size="standard"
|
||||||
|
:model-value="sliderValue"
|
||||||
|
:min="inputMin ?? 0"
|
||||||
|
:max="inputMax ?? 100"
|
||||||
|
:step="inputStep ?? 1"
|
||||||
|
:snap-points="setting.category_id === 'music_and_sound' ? [0, 50, 100] : []"
|
||||||
|
:snap-range="5"
|
||||||
|
:unit="setting.editor.unit === 'percent' ? '%' : undefined"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:aria-label="settingLabel"
|
||||||
|
:disabled="editorDisabled"
|
||||||
|
@update:model-value="updateValue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GameSettingBooleanControl
|
||||||
|
v-else-if="setting.editor.type === 'bool'"
|
||||||
|
:model-value="booleanValue"
|
||||||
|
:label="settingLabel"
|
||||||
|
:on-label="formatMessage(messages.on)"
|
||||||
|
:off-label="formatMessage(messages.off)"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="editorDisabled"
|
||||||
|
@update:model-value="updateValue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Combobox
|
||||||
|
v-else-if="setting.option_id === 'language'"
|
||||||
|
:model-value="valueText"
|
||||||
|
:options="languageOptions"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="editorDisabled"
|
||||||
|
:aria-label="settingLabel"
|
||||||
|
:search-input-attrs="{ 'aria-label': settingLabel }"
|
||||||
|
searchable
|
||||||
|
select-search-text-on-focus
|
||||||
|
class="min-w-0"
|
||||||
|
@update:model-value="updateValue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Combobox
|
||||||
|
v-else-if="setting.editor.type === 'enum'"
|
||||||
|
:model-value="valueText"
|
||||||
|
:options="enumOptions"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="editorDisabled"
|
||||||
|
:aria-label="settingLabel"
|
||||||
|
class="min-w-0"
|
||||||
|
@update:model-value="updateValue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GameKeybindInput
|
||||||
|
v-else-if="isKeybindSetting(setting)"
|
||||||
|
:model-value="valueText"
|
||||||
|
:setting-label="settingLabel"
|
||||||
|
:conflicts="keybindConflicts"
|
||||||
|
:mixed="setting.value_state === 'mixed'"
|
||||||
|
:disabled="editorDisabled"
|
||||||
|
@update:model-value="updateValue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StyledInput
|
||||||
|
v-else
|
||||||
|
:model-value="inputValue"
|
||||||
|
:type="isNumber ? 'number' : 'text'"
|
||||||
|
:min="inputMin"
|
||||||
|
:max="inputMax"
|
||||||
|
:step="inputStep"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="editorDisabled"
|
||||||
|
:aria-label="settingLabel"
|
||||||
|
wrapper-class="w-full"
|
||||||
|
@update:model-value="updateValue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="showSyncToggle"
|
||||||
|
v-tooltip="syncToggleDisabled && syncDisabledReason ? syncDisabledReason : syncStatusTooltip"
|
||||||
|
class="flex justify-center"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
:type="setting.sync_enabled ? 'chip' : 'base'"
|
||||||
|
:color="setting.sync_enabled ? 'blue' : undefined"
|
||||||
|
:label="syncActionLabel"
|
||||||
|
:disabled="syncToggleDisabled"
|
||||||
|
:aria-pressed="setting.sync_enabled"
|
||||||
|
@click="emit('update:sync-enabled', !setting.sync_enabled)"
|
||||||
|
>
|
||||||
|
<LinkIcon v-if="setting.sync_enabled" aria-hidden="true" />
|
||||||
|
<UnlinkIcon v-else aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+448
@@ -0,0 +1,448 @@
|
|||||||
|
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { computed, type MaybeRefOrGetter, nextTick, onScopeDispose, ref, toValue, watch } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type EditableGameSetting,
|
||||||
|
type GameOptionCanonicalValue,
|
||||||
|
type GameSettingChange,
|
||||||
|
type GameSettingsEditorState,
|
||||||
|
gameSettingsKeys,
|
||||||
|
gameSettingsQueryOptions,
|
||||||
|
get_local_game_options_config,
|
||||||
|
get_synced_game_options_config,
|
||||||
|
preview_local_game_option_changes,
|
||||||
|
preview_synced_game_option_changes,
|
||||||
|
save_local_game_option_changes,
|
||||||
|
save_synced_game_option_changes,
|
||||||
|
type UpdateGameSettingsRequest,
|
||||||
|
} from '@/helpers/game-options'
|
||||||
|
|
||||||
|
import { canonicalValuesEqual, cloneGameSettingsState, gameSettingChanges } from './editors'
|
||||||
|
|
||||||
|
export function useGameSettingsEditor(
|
||||||
|
instanceId: MaybeRefOrGetter<string | undefined>,
|
||||||
|
onSaved: () => void,
|
||||||
|
) {
|
||||||
|
const { addNotification, handleError } = injectNotificationManager()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const messages = defineMessages({
|
||||||
|
conflictTitle: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.conflict-title',
|
||||||
|
defaultMessage: 'These settings changed elsewhere',
|
||||||
|
},
|
||||||
|
conflictText: {
|
||||||
|
id: 'app.settings.synced-options.game-settings.conflict-text',
|
||||||
|
defaultMessage: 'We loaded the latest settings. Check your changes, then save again.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseState = ref<GameSettingsEditorState | null>(null)
|
||||||
|
const draftState = ref<GameSettingsEditorState | null>(null)
|
||||||
|
const touchedValueOptionIds = ref<Set<string>>(new Set())
|
||||||
|
let previewTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let previewGeneration = 0
|
||||||
|
let loadGeneration = 0
|
||||||
|
let saveGeneration = 0
|
||||||
|
|
||||||
|
const editorInstanceId = ref<string>()
|
||||||
|
const isLocalEditor = computed(() => !!editorInstanceId.value)
|
||||||
|
|
||||||
|
const dirtyChanges = computed(() =>
|
||||||
|
gameSettingChanges(baseState.value, draftState.value, touchedValueOptionIds.value),
|
||||||
|
)
|
||||||
|
const isDirty = computed(() => dirtyChanges.value.length > 0)
|
||||||
|
const dirtyOptionIds = computed(
|
||||||
|
() => new Set(dirtyChanges.value.map((change) => change.option_id)),
|
||||||
|
)
|
||||||
|
const hasBlockingDraft = computed(
|
||||||
|
() =>
|
||||||
|
draftState.value?.settings.some(
|
||||||
|
(setting) =>
|
||||||
|
dirtyOptionIds.value.has(setting.option_id) &&
|
||||||
|
(isLocalEditor.value || setting.sync_enabled) &&
|
||||||
|
(!!setting.validation_error ||
|
||||||
|
['mixed', 'unset', 'invalid'].includes(setting.value_state)),
|
||||||
|
) ?? false,
|
||||||
|
)
|
||||||
|
const stateQuery = useQuery(
|
||||||
|
computed(() => ({
|
||||||
|
...gameSettingsQueryOptions(editorInstanceId.value),
|
||||||
|
enabled: false,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const loading = computed(
|
||||||
|
() => !draftState.value && (stateQuery.isPending.value || stateQuery.isFetching.value),
|
||||||
|
)
|
||||||
|
const loadError = computed(() => !draftState.value && stateQuery.isError.value)
|
||||||
|
const previewMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
}: {
|
||||||
|
instanceId?: string
|
||||||
|
request: UpdateGameSettingsRequest
|
||||||
|
}) =>
|
||||||
|
instanceId
|
||||||
|
? preview_local_game_option_changes(instanceId, request)
|
||||||
|
: preview_synced_game_option_changes(request),
|
||||||
|
})
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: saveDraft,
|
||||||
|
onError: handleError,
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyState(state: GameSettingsEditorState) {
|
||||||
|
baseState.value = cloneGameSettingsState(state)
|
||||||
|
draftState.value = cloneGameSettingsState(state)
|
||||||
|
touchedValueOptionIds.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(stateQuery.data, (state) => {
|
||||||
|
if (state && draftState.value && !isDirty.value && !saveMutation.isPending.value) {
|
||||||
|
applyState(state)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const generation = ++loadGeneration
|
||||||
|
cancelPreview()
|
||||||
|
editorInstanceId.value = toValue(instanceId)
|
||||||
|
const options = gameSettingsQueryOptions(editorInstanceId.value)
|
||||||
|
const cached = !editorInstanceId.value && queryClient.getQueryData(options.queryKey)
|
||||||
|
if (cached) {
|
||||||
|
applyState(cached)
|
||||||
|
try {
|
||||||
|
// A previously cached empty state is common immediately after enabling
|
||||||
|
// sync. Refresh it before returning so the editor does not stay empty.
|
||||||
|
const refreshed = await queryClient.fetchQuery({ ...options, staleTime: 0 })
|
||||||
|
if (generation !== loadGeneration) return false
|
||||||
|
applyState(refreshed)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (generation !== loadGeneration) return false
|
||||||
|
handleError(error)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
const result = await queryClient
|
||||||
|
.fetchQuery({ ...options, staleTime: 0 })
|
||||||
|
.then((data) => ({ data, isError: false, error: null as unknown }))
|
||||||
|
.catch((error) => ({ data: undefined, isError: true, error }))
|
||||||
|
if (generation !== loadGeneration) return false
|
||||||
|
if (result.isError) {
|
||||||
|
handleError(result.error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!result.data) return false
|
||||||
|
applyState(result.data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPreview() {
|
||||||
|
if (previewTimer) clearTimeout(previewTimer)
|
||||||
|
previewTimer = null
|
||||||
|
previewGeneration++
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
cancelPreview()
|
||||||
|
loadGeneration++
|
||||||
|
saveGeneration++
|
||||||
|
baseState.value = null
|
||||||
|
draftState.value = null
|
||||||
|
touchedValueOptionIds.value = new Set()
|
||||||
|
saveMutation.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelChanges() {
|
||||||
|
if (!baseState.value) return
|
||||||
|
cancelPreview()
|
||||||
|
draftState.value = cloneGameSettingsState(baseState.value)
|
||||||
|
touchedValueOptionIds.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSyncEnabled(optionIds: readonly string[], enabled: boolean) {
|
||||||
|
if (!draftState.value) return
|
||||||
|
const ids = new Set(optionIds)
|
||||||
|
draftState.value.settings = draftState.value.settings.map((setting) =>
|
||||||
|
ids.has(setting.option_id)
|
||||||
|
? { ...setting, sync_enabled: enabled, validation_error: null }
|
||||||
|
: setting,
|
||||||
|
)
|
||||||
|
schedulePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePreview() {
|
||||||
|
cancelPreview()
|
||||||
|
previewTimer = setTimeout(() => void preview(), 350)
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (saveMutation.isPending.value || loading.value || hasBlockingDraft.value || !isDirty.value)
|
||||||
|
return
|
||||||
|
saveMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAndWait(): Promise<boolean> {
|
||||||
|
if (loading.value || hasBlockingDraft.value || !isDirty.value) return !hasBlockingDraft.value
|
||||||
|
try {
|
||||||
|
await saveMutation.mutateAsync()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorRequest(): UpdateGameSettingsRequest | null {
|
||||||
|
if (!baseState.value || !draftState.value) return null
|
||||||
|
return {
|
||||||
|
expected_summary_revision: draftState.value.summary_revision,
|
||||||
|
expected_canonical_revision: draftState.value.canonical_revision,
|
||||||
|
expected_catalog_revision: draftState.value.catalog_revision,
|
||||||
|
changes: dirtyChanges.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCanonicalValue(optionId: string, value: GameOptionCanonicalValue | null) {
|
||||||
|
if (!draftState.value) return
|
||||||
|
const currentSetting = draftState.value.settings.find(
|
||||||
|
(setting) => setting.option_id === optionId,
|
||||||
|
)
|
||||||
|
const baseSetting = baseState.value?.settings.find((setting) => setting.option_id === optionId)
|
||||||
|
if (!currentSetting) return
|
||||||
|
|
||||||
|
const revertedToBase =
|
||||||
|
!!baseSetting &&
|
||||||
|
!canonicalValuesEqual(currentSetting.canonical_value, baseSetting.canonical_value) &&
|
||||||
|
canonicalValuesEqual(value, baseSetting.canonical_value)
|
||||||
|
const touchedOptionIds = new Set(touchedValueOptionIds.value)
|
||||||
|
if (revertedToBase) {
|
||||||
|
touchedOptionIds.delete(optionId)
|
||||||
|
} else {
|
||||||
|
touchedOptionIds.add(optionId)
|
||||||
|
}
|
||||||
|
touchedValueOptionIds.value = touchedOptionIds
|
||||||
|
draftState.value.settings = draftState.value.settings.map((setting) =>
|
||||||
|
setting.option_id === optionId
|
||||||
|
? {
|
||||||
|
...setting,
|
||||||
|
canonical_value: value,
|
||||||
|
value_state: revertedToBase ? baseSetting.value_state : value ? 'canonical' : 'unset',
|
||||||
|
validation_error: revertedToBase ? baseSetting.validation_error : null,
|
||||||
|
}
|
||||||
|
: setting,
|
||||||
|
)
|
||||||
|
schedulePreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePreview(preview: GameSettingsEditorState) {
|
||||||
|
if (!baseState.value || !draftState.value) return
|
||||||
|
const previousBase = cloneGameSettingsState(baseState.value)
|
||||||
|
const previousDraft = cloneGameSettingsState(draftState.value)
|
||||||
|
const dirtyIds = new Set(
|
||||||
|
gameSettingChanges(previousBase, previousDraft, touchedValueOptionIds.value).map(
|
||||||
|
(change) => change.option_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const stagedSettings = new Map(
|
||||||
|
previousDraft.settings
|
||||||
|
.filter((setting) => dirtyIds.has(setting.option_id))
|
||||||
|
.map((setting) => [setting.option_id, setting]),
|
||||||
|
)
|
||||||
|
const previousBaseSettings = new Map(
|
||||||
|
previousBase.settings.map((setting) => [setting.option_id, setting]),
|
||||||
|
)
|
||||||
|
const nextBase = cloneGameSettingsState(preview)
|
||||||
|
const nextDraft = cloneGameSettingsState(preview)
|
||||||
|
nextBase.settings = nextBase.settings.map((setting) =>
|
||||||
|
dirtyIds.has(setting.option_id)
|
||||||
|
? { ...(previousBaseSettings.get(setting.option_id) ?? setting) }
|
||||||
|
: setting,
|
||||||
|
)
|
||||||
|
nextDraft.settings = nextDraft.settings.map((setting): EditableGameSetting => {
|
||||||
|
const staged = stagedSettings.get(setting.option_id)
|
||||||
|
if (!staged) return setting
|
||||||
|
return {
|
||||||
|
...setting,
|
||||||
|
sync_enabled: staged.sync_enabled,
|
||||||
|
canonical_value: staged.canonical_value,
|
||||||
|
option_revision: staged.option_revision,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
baseState.value = nextBase
|
||||||
|
draftState.value = nextDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preview() {
|
||||||
|
const request = editorRequest()
|
||||||
|
if (!request || request.changes.length === 0) {
|
||||||
|
previewGeneration++
|
||||||
|
if (baseState.value) draftState.value = cloneGameSettingsState(baseState.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = ++previewGeneration
|
||||||
|
try {
|
||||||
|
const previewState = await previewMutation.mutateAsync({
|
||||||
|
instanceId: editorInstanceId.value,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
if (generation !== previewGeneration) return
|
||||||
|
mergePreview(previewState)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyChangesToRefreshedState(
|
||||||
|
refreshed: GameSettingsEditorState,
|
||||||
|
stagedDraft: GameSettingsEditorState,
|
||||||
|
changes: GameSettingChange[],
|
||||||
|
touchedOptionIds: ReadonlySet<string>,
|
||||||
|
conflictOptionIds: ReadonlySet<string> = new Set(),
|
||||||
|
) {
|
||||||
|
const changesById = new Map(changes.map((change) => [change.option_id, change]))
|
||||||
|
const stagedSettings = new Map(
|
||||||
|
stagedDraft.settings.map((setting) => [setting.option_id, setting]),
|
||||||
|
)
|
||||||
|
const nextBase = cloneGameSettingsState(refreshed)
|
||||||
|
const nextDraft = cloneGameSettingsState(refreshed)
|
||||||
|
|
||||||
|
nextDraft.settings = nextDraft.settings.map((setting) => {
|
||||||
|
const change = changesById.get(setting.option_id)
|
||||||
|
const staged = stagedSettings.get(setting.option_id)
|
||||||
|
if (!change || !staged) return setting
|
||||||
|
|
||||||
|
return {
|
||||||
|
...setting,
|
||||||
|
...(change.sync_enabled !== undefined ? { sync_enabled: staged.sync_enabled } : {}),
|
||||||
|
...(change.canonical_value !== undefined
|
||||||
|
? {
|
||||||
|
canonical_value: staged.canonical_value,
|
||||||
|
value_state: staged.value_state,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
validation_error: conflictOptionIds.has(setting.option_id)
|
||||||
|
? 'changed_since_opened'
|
||||||
|
: staged.validation_error,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
baseState.value = nextBase
|
||||||
|
draftState.value = nextDraft
|
||||||
|
touchedValueOptionIds.value = new Set(
|
||||||
|
[...touchedOptionIds].filter(
|
||||||
|
(optionId) => changesById.get(optionId)?.canonical_value !== undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
const request = editorRequest()
|
||||||
|
if (!request || request.changes.length === 0 || !baseState.value || !draftState.value) return
|
||||||
|
|
||||||
|
cancelPreview()
|
||||||
|
const targetInstanceId = editorInstanceId.value
|
||||||
|
const generation = ++saveGeneration
|
||||||
|
const previousBase = cloneGameSettingsState(baseState.value)
|
||||||
|
const optimisticState = cloneGameSettingsState(draftState.value)
|
||||||
|
const previouslyTouchedOptionIds = new Set(touchedValueOptionIds.value)
|
||||||
|
baseState.value = cloneGameSettingsState(optimisticState)
|
||||||
|
touchedValueOptionIds.value = new Set()
|
||||||
|
try {
|
||||||
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: gameSettingsQueryOptions(targetInstanceId).queryKey,
|
||||||
|
})
|
||||||
|
const result = targetInstanceId
|
||||||
|
? await save_local_game_option_changes(targetInstanceId, request)
|
||||||
|
: await save_synced_game_option_changes(request)
|
||||||
|
const refreshed =
|
||||||
|
result.state ??
|
||||||
|
(targetInstanceId
|
||||||
|
? await get_local_game_options_config(targetInstanceId)
|
||||||
|
: await get_synced_game_options_config())
|
||||||
|
queryClient.setQueryData(gameSettingsQueryOptions(targetInstanceId).queryKey, refreshed)
|
||||||
|
if (targetInstanceId) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: gameSettingsKeys.synced })
|
||||||
|
}
|
||||||
|
if (result.conflicts?.length) {
|
||||||
|
if (generation !== saveGeneration || !draftState.value) return
|
||||||
|
previewGeneration++
|
||||||
|
const stagedDraft = cloneGameSettingsState(draftState.value)
|
||||||
|
const retainedTouchedOptionIds = new Set([
|
||||||
|
...previouslyTouchedOptionIds,
|
||||||
|
...touchedValueOptionIds.value,
|
||||||
|
])
|
||||||
|
const stagedChanges = gameSettingChanges(
|
||||||
|
previousBase,
|
||||||
|
stagedDraft,
|
||||||
|
retainedTouchedOptionIds,
|
||||||
|
)
|
||||||
|
const conflicts = new Set(result.conflicts)
|
||||||
|
applyChangesToRefreshedState(
|
||||||
|
refreshed,
|
||||||
|
stagedDraft,
|
||||||
|
stagedChanges,
|
||||||
|
retainedTouchedOptionIds,
|
||||||
|
conflicts,
|
||||||
|
)
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'warning',
|
||||||
|
title: formatMessage(messages.conflictTitle),
|
||||||
|
text: formatMessage(messages.conflictText),
|
||||||
|
})
|
||||||
|
if (isDirty.value) {
|
||||||
|
schedulePreview()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation !== saveGeneration) {
|
||||||
|
onSaved()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!baseState.value || !draftState.value) return
|
||||||
|
previewGeneration++
|
||||||
|
const stagedDraft = cloneGameSettingsState(draftState.value)
|
||||||
|
const touchedSinceSave = new Set(touchedValueOptionIds.value)
|
||||||
|
const changesSinceSave = gameSettingChanges(optimisticState, stagedDraft, touchedSinceSave)
|
||||||
|
applyChangesToRefreshedState(refreshed, stagedDraft, changesSinceSave, touchedSinceSave)
|
||||||
|
if (changesSinceSave.length > 0) schedulePreview()
|
||||||
|
onSaved()
|
||||||
|
} catch (error) {
|
||||||
|
if (generation === saveGeneration && draftState.value) {
|
||||||
|
previewGeneration++
|
||||||
|
baseState.value = previousBase
|
||||||
|
touchedValueOptionIds.value = new Set([
|
||||||
|
...previouslyTouchedOptionIds,
|
||||||
|
...touchedValueOptionIds.value,
|
||||||
|
])
|
||||||
|
if (dirtyChanges.value.length > 0) schedulePreview()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(reset)
|
||||||
|
|
||||||
|
return {
|
||||||
|
draftState,
|
||||||
|
isLocalEditor,
|
||||||
|
isDirty,
|
||||||
|
hasBlockingDraft,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
saving: saveMutation.isPending,
|
||||||
|
load,
|
||||||
|
reset,
|
||||||
|
cancelChanges,
|
||||||
|
setSyncEnabled,
|
||||||
|
setCanonicalValue,
|
||||||
|
save,
|
||||||
|
saveAndWait,
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
import { useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||||
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
|
import { computed, type MaybeRefOrGetter, onScopeDispose, shallowRef, toValue, watch } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type EditableGameSetting,
|
||||||
|
type GameSettingLocaleLabel,
|
||||||
|
get_game_setting_locale_labels,
|
||||||
|
} from '@/helpers/game-options'
|
||||||
|
|
||||||
|
export function useGameSettingLabels(
|
||||||
|
opened: MaybeRefOrGetter<boolean>,
|
||||||
|
instanceId: MaybeRefOrGetter<string | undefined>,
|
||||||
|
settings: MaybeRefOrGetter<EditableGameSetting[]>,
|
||||||
|
) {
|
||||||
|
const debug = useDebugLogger('GameSettingLabels')
|
||||||
|
const { locale } = useVIntl()
|
||||||
|
const labels = shallowRef<Record<string, GameSettingLocaleLabel>>({})
|
||||||
|
const optionIds = computed(() =>
|
||||||
|
toValue(settings)
|
||||||
|
.filter((setting) => setting.kind === 'external')
|
||||||
|
.map((setting) => setting.option_id)
|
||||||
|
.sort(),
|
||||||
|
)
|
||||||
|
let generation = 0
|
||||||
|
let stopListening: UnlistenFn | undefined
|
||||||
|
let refreshSources = false
|
||||||
|
|
||||||
|
debug('setup', { opened: toValue(opened), instanceId: toValue(instanceId), locale: locale.value })
|
||||||
|
|
||||||
|
async function refresh(reason: string) {
|
||||||
|
if (!toValue(opened) || !optionIds.value.length) {
|
||||||
|
debug('refresh skipped', {
|
||||||
|
reason,
|
||||||
|
opened: toValue(opened),
|
||||||
|
optionCount: optionIds.value.length,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const request = ++generation
|
||||||
|
const reindex = refreshSources
|
||||||
|
const requestedIds = [...optionIds.value]
|
||||||
|
const started = performance.now()
|
||||||
|
refreshSources = false
|
||||||
|
debug('request started', {
|
||||||
|
request,
|
||||||
|
reason,
|
||||||
|
instanceId: toValue(instanceId),
|
||||||
|
locale: locale.value,
|
||||||
|
reindex,
|
||||||
|
optionIds: requestedIds,
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const result = await get_game_setting_locale_labels(
|
||||||
|
toValue(instanceId),
|
||||||
|
locale.value,
|
||||||
|
requestedIds,
|
||||||
|
reindex,
|
||||||
|
)
|
||||||
|
const applied = request === generation && toValue(opened)
|
||||||
|
debug('request completed', {
|
||||||
|
request,
|
||||||
|
generation,
|
||||||
|
applied,
|
||||||
|
opened: toValue(opened),
|
||||||
|
elapsedMs: Math.round(performance.now() - started),
|
||||||
|
returnedCount: Object.keys(result.settings).length,
|
||||||
|
missingOptionIds: requestedIds.filter((id) => !result.settings[id]),
|
||||||
|
labelSample: Object.entries(result.settings).slice(0, 8),
|
||||||
|
})
|
||||||
|
if (applied) labels.value = result.settings
|
||||||
|
} catch (error) {
|
||||||
|
debug('request failed', {
|
||||||
|
request,
|
||||||
|
reason,
|
||||||
|
elapsedMs: Math.round(performance.now() - started),
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => toValue(opened),
|
||||||
|
(active, _, onCleanup) => {
|
||||||
|
debug('modal state changed', { active, generation, optionCount: optionIds.value.length })
|
||||||
|
let cancelled = false
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelled = true
|
||||||
|
stopListening?.()
|
||||||
|
stopListening = undefined
|
||||||
|
generation++
|
||||||
|
labels.value = {}
|
||||||
|
debug('modal cleanup: listener removed and labels cleared', { generation })
|
||||||
|
})
|
||||||
|
if (!active) return
|
||||||
|
refreshSources = true
|
||||||
|
debug('registering locale update listener')
|
||||||
|
void listen('game-option-locales-updated', () => {
|
||||||
|
debug('received game-option-locales-updated')
|
||||||
|
void refresh('locale update event')
|
||||||
|
})
|
||||||
|
.then((unlisten) => {
|
||||||
|
debug('locale update listener registered', { cancelled })
|
||||||
|
if (cancelled) unlisten()
|
||||||
|
else {
|
||||||
|
stopListening = unlisten
|
||||||
|
void refresh('listener registered')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
debug('locale update listener failed', { cancelled, error })
|
||||||
|
if (!cancelled) void refresh('listener failed')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ flush: 'sync' },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch([locale, () => toValue(instanceId), () => optionIds.value.join('\n')], () => {
|
||||||
|
generation++
|
||||||
|
labels.value = {}
|
||||||
|
debug('locale, instance or option IDs changed: labels cleared', {
|
||||||
|
generation,
|
||||||
|
locale: locale.value,
|
||||||
|
instanceId: toValue(instanceId),
|
||||||
|
optionCount: optionIds.value.length,
|
||||||
|
})
|
||||||
|
void refresh('request inputs changed')
|
||||||
|
})
|
||||||
|
|
||||||
|
onScopeDispose(() => {
|
||||||
|
generation++
|
||||||
|
stopListening?.()
|
||||||
|
labels.value = {}
|
||||||
|
debug('scope disposed', { generation })
|
||||||
|
})
|
||||||
|
|
||||||
|
return labels
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
EditIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
|
NoSignalIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SaveIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SignalIcon,
|
||||||
|
TrashIcon,
|
||||||
|
XIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
type ButtonMenuOption,
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
FilterPills,
|
||||||
|
injectNotificationManager,
|
||||||
|
NewModal,
|
||||||
|
StyledInput,
|
||||||
|
Table,
|
||||||
|
type TableColumn,
|
||||||
|
TeleportOverflowMenu,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import { remove_synced_server, type SyncedServer, update_synced_server } from '@/helpers/instance'
|
||||||
|
import { syncedOptionsKeys, syncedServersQueryOptions } from '@/helpers/synced-options'
|
||||||
|
import { copyToClipboard } from '@/helpers/utils'
|
||||||
|
import {
|
||||||
|
refreshServerData,
|
||||||
|
refreshServers,
|
||||||
|
type ServerData,
|
||||||
|
type ServerWorld,
|
||||||
|
} from '@/helpers/worlds'
|
||||||
|
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const serverEditorModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||||
|
const editServerModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||||
|
const editedServer = ref<SyncedServer | null>(null)
|
||||||
|
const serversQuery = useQuery(syncedServersQueryOptions())
|
||||||
|
const syncedServers = computed(() => serversQuery.data.value ?? [])
|
||||||
|
const serverData = ref<Record<string, ServerData>>({})
|
||||||
|
const serverSearch = ref('')
|
||||||
|
const selectedServerFilters = ref<string[]>([])
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
serverEditorTitle: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.editor-title',
|
||||||
|
defaultMessage: 'Edit synced servers',
|
||||||
|
},
|
||||||
|
editServerTitle: {
|
||||||
|
id: 'instance.edit-server.title',
|
||||||
|
defaultMessage: 'Edit server',
|
||||||
|
},
|
||||||
|
serverName: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.name',
|
||||||
|
defaultMessage: 'Server name',
|
||||||
|
},
|
||||||
|
serverAddress: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.address',
|
||||||
|
defaultMessage: 'Server address',
|
||||||
|
},
|
||||||
|
searchServers: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.search',
|
||||||
|
defaultMessage:
|
||||||
|
'Search {count, number} {count, plural, one {synced server} other {synced servers}}...',
|
||||||
|
},
|
||||||
|
serverStatus: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.status',
|
||||||
|
defaultMessage: 'Status',
|
||||||
|
},
|
||||||
|
onlineFilter: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.filter-online',
|
||||||
|
defaultMessage: 'Online',
|
||||||
|
},
|
||||||
|
offlineFilter: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.filter-offline',
|
||||||
|
defaultMessage: 'Offline',
|
||||||
|
},
|
||||||
|
playersOnline: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.players-online',
|
||||||
|
defaultMessage: '{count, number} online',
|
||||||
|
},
|
||||||
|
noMatchingServers: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.no-results',
|
||||||
|
defaultMessage: 'No synced servers match your search or filters.',
|
||||||
|
},
|
||||||
|
serverCount: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.count',
|
||||||
|
defaultMessage: '{count, plural, one {# server} other {# servers}}',
|
||||||
|
},
|
||||||
|
moreServerOptions: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.more-options',
|
||||||
|
defaultMessage: 'More server options',
|
||||||
|
},
|
||||||
|
copyServerAddress: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.copy-address',
|
||||||
|
defaultMessage: 'Copy address',
|
||||||
|
},
|
||||||
|
noSyncedServers: {
|
||||||
|
id: 'app.settings.synced-options.multiplayer-servers.empty',
|
||||||
|
defaultMessage: 'No user-added servers are currently synced.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverWorlds = computed<ServerWorld[]>(() =>
|
||||||
|
syncedServers.value.map((server, index) => ({
|
||||||
|
name: server.name,
|
||||||
|
type: 'server',
|
||||||
|
index,
|
||||||
|
server_id: server.id,
|
||||||
|
address: server.address,
|
||||||
|
pack_status:
|
||||||
|
server.accept_textures === true
|
||||||
|
? 'enabled'
|
||||||
|
: server.accept_textures === false
|
||||||
|
? 'disabled'
|
||||||
|
: 'prompt',
|
||||||
|
display_status: 'normal',
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncedServerTableColumn = 'server' | 'status' | 'version' | 'actions'
|
||||||
|
type SyncedServerTableRow = SyncedServer & {
|
||||||
|
server: string
|
||||||
|
status: 'online' | 'offline'
|
||||||
|
version: string
|
||||||
|
actions: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncedServerColumns = computed<TableColumn<SyncedServerTableColumn>[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'server',
|
||||||
|
label: formatMessage(commonMessages.serverLabel),
|
||||||
|
width: '45%',
|
||||||
|
cellClass: '!h-16',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: formatMessage(messages.serverStatus),
|
||||||
|
width: '25%',
|
||||||
|
cellClass: '!h-16',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'version',
|
||||||
|
label: formatMessage(commonMessages.versionLabel),
|
||||||
|
width: '22%',
|
||||||
|
cellClass: '!h-16',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: formatMessage(commonMessages.actionsLabel),
|
||||||
|
align: 'right',
|
||||||
|
width: '6rem',
|
||||||
|
cellClass: '!h-16',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const syncedServerFilterOptions = computed(() => [
|
||||||
|
{ id: 'online', label: formatMessage(messages.onlineFilter) },
|
||||||
|
{ id: 'offline', label: formatMessage(messages.offlineFilter) },
|
||||||
|
])
|
||||||
|
|
||||||
|
const syncedServerRows = computed<SyncedServerTableRow[]>(() =>
|
||||||
|
syncedServers.value.map((server) => ({
|
||||||
|
...server,
|
||||||
|
server: server.name,
|
||||||
|
status: serverData.value[server.address]?.status ? 'online' : 'offline',
|
||||||
|
version: serverData.value[server.address]?.status?.version?.name ?? '',
|
||||||
|
actions: null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusFilteredSyncedServerRows = computed(() => {
|
||||||
|
if (selectedServerFilters.value.length === 0) return syncedServerRows.value
|
||||||
|
return syncedServerRows.value.filter((server) =>
|
||||||
|
selectedServerFilters.value.includes(server.status),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredSyncedServerRows = computed(() => {
|
||||||
|
const search = serverSearch.value.trim().toLocaleLowerCase()
|
||||||
|
if (!search) return statusFilteredSyncedServerRows.value
|
||||||
|
return statusFilteredSyncedServerRows.value.filter(
|
||||||
|
(server) =>
|
||||||
|
server.name.toLocaleLowerCase().includes(search) ||
|
||||||
|
server.address.toLocaleLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshMutation = useMutation({
|
||||||
|
mutationFn: () => refreshServers(serverWorlds.value, serverData.value, null),
|
||||||
|
onError: handleError,
|
||||||
|
})
|
||||||
|
const refreshingAllSyncedServers = refreshMutation.isPending
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: update_synced_server,
|
||||||
|
onSuccess: async (_, server) => {
|
||||||
|
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
|
||||||
|
servers?.map((current) => (current.id === server.id ? { ...server } : current)),
|
||||||
|
)
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
|
||||||
|
void refreshSyncedServer(server.address).catch(handleError)
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
})
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: remove_synced_server,
|
||||||
|
onSuccess: async (_, serverId) => {
|
||||||
|
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
|
||||||
|
servers?.filter((server) => server.id !== serverId),
|
||||||
|
)
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
const result = await serversQuery.refetch()
|
||||||
|
if (result.isError) {
|
||||||
|
handleError(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverSearch.value = ''
|
||||||
|
selectedServerFilters.value = []
|
||||||
|
serverData.value = {}
|
||||||
|
serverEditorModal.value?.show()
|
||||||
|
refreshAllSyncedServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSyncedServerEditor(server: SyncedServer) {
|
||||||
|
editedServer.value = { ...server }
|
||||||
|
editServerModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSyncedServer() {
|
||||||
|
if (!editedServer.value || saveMutation.isPending.value) return
|
||||||
|
try {
|
||||||
|
await saveMutation.mutateAsync({ ...editedServer.value })
|
||||||
|
await nextTick()
|
||||||
|
editServerModal.value?.hide()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSyncedServer(address: string) {
|
||||||
|
serverData.value[address] ??= { refreshing: true }
|
||||||
|
await refreshServerData(serverData.value[address], null, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshAllSyncedServers() {
|
||||||
|
if (!refreshingAllSyncedServers.value) refreshMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncedServerMenuOptions(server: SyncedServer): ButtonMenuOption[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'refresh',
|
||||||
|
label: formatMessage(commonMessages.refreshButton),
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
action: () => refreshSyncedServer(server.address).catch(handleError),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-address',
|
||||||
|
label: formatMessage(messages.copyServerAddress),
|
||||||
|
icon: ClipboardCopyIcon,
|
||||||
|
action: () => copyToClipboard(server.address),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
label: formatMessage(commonMessages.editButton),
|
||||||
|
icon: EditIcon,
|
||||||
|
action: () => openSyncedServerEditor(server),
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
id: 'remove',
|
||||||
|
label: formatMessage(commonMessages.removeButton),
|
||||||
|
icon: TrashIcon,
|
||||||
|
tone: 'red',
|
||||||
|
disabled: removeMutation.isPending.value,
|
||||||
|
action: () => removeMutation.mutate(server.id),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="serverEditorModal"
|
||||||
|
:header="formatMessage(messages.serverEditorTitle)"
|
||||||
|
no-padding
|
||||||
|
max-width="min(928px, calc(100vw - 2rem))"
|
||||||
|
width="min(928px, calc(100vw - 2rem))"
|
||||||
|
>
|
||||||
|
<div class="flex max-h-[min(600px,calc(95vh-10rem))] flex-col">
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 flex-col gap-4 border-0 border-b border-solid border-surface-4 px-6 py-4"
|
||||||
|
>
|
||||||
|
<StyledInput
|
||||||
|
v-model="serverSearch"
|
||||||
|
:icon="SearchIcon"
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
:spellcheck="false"
|
||||||
|
:placeholder="formatMessage(messages.searchServers, { count: syncedServers.length })"
|
||||||
|
size="medium"
|
||||||
|
:aria-label="formatMessage(messages.searchServers, { count: syncedServers.length })"
|
||||||
|
:clear-label="formatMessage(commonMessages.clearButton)"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<FilterPills v-model="selectedServerFilters" :options="syncedServerFilterOptions">
|
||||||
|
<template #all>
|
||||||
|
{{ formatMessage(commonMessages.allProjectType) }}
|
||||||
|
</template>
|
||||||
|
</FilterPills>
|
||||||
|
<Button
|
||||||
|
type="quiet"
|
||||||
|
:disabled="refreshingAllSyncedServers || syncedServers.length === 0"
|
||||||
|
interaction="none"
|
||||||
|
@click="refreshAllSyncedServers"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon
|
||||||
|
:class="{ 'animate-spin': refreshingAllSyncedServers }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ formatMessage(commonMessages.refreshButton) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
v-if="syncedServers.length === 0"
|
||||||
|
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.noSyncedServers) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="filteredSyncedServerRows.length === 0"
|
||||||
|
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.noMatchingServers) }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<Table
|
||||||
|
:columns="syncedServerColumns"
|
||||||
|
:data="filteredSyncedServerRows"
|
||||||
|
row-key="id"
|
||||||
|
table-min-width="44rem"
|
||||||
|
class="!rounded-none !border-0"
|
||||||
|
>
|
||||||
|
<template #cell-server="{ row }">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
:src="serverData[row.address]?.status?.favicon"
|
||||||
|
:alt="row.name"
|
||||||
|
:tint-by="row.address"
|
||||||
|
size="32px"
|
||||||
|
no-shadow
|
||||||
|
class="shrink-0 !rounded-lg"
|
||||||
|
/>
|
||||||
|
<div class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
|
||||||
|
row.name
|
||||||
|
}}</span>
|
||||||
|
<span class="truncate text-sm text-[var(--color-text-tertiary)]">{{
|
||||||
|
row.address
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell-status="{ row }">
|
||||||
|
<span class="inline-flex items-center gap-1.5 font-medium">
|
||||||
|
<RefreshCwIcon
|
||||||
|
v-if="serverData[row.address]?.refreshing"
|
||||||
|
class="size-4 shrink-0 animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<SignalIcon
|
||||||
|
v-else-if="serverData[row.address]?.status"
|
||||||
|
class="size-4 shrink-0 text-green"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<NoSignalIcon v-else class="size-4 shrink-0" aria-hidden="true" />
|
||||||
|
<template v-if="serverData[row.address]?.refreshing">
|
||||||
|
{{ formatMessage(commonMessages.loadingLabel) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="serverData[row.address]?.status">
|
||||||
|
{{
|
||||||
|
formatMessage(messages.playersOnline, {
|
||||||
|
count: serverData[row.address]?.status?.players?.online ?? 0,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ formatMessage(messages.offlineFilter) }}</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-version="{ row }">
|
||||||
|
<span class="block truncate">{{ row.version || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-actions="{ row }">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<TeleportOverflowMenu
|
||||||
|
type="quiet"
|
||||||
|
:label="formatMessage(messages.moreServerOptions)"
|
||||||
|
:options="syncedServerMenuOptions(row)"
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon aria-hidden="true" />
|
||||||
|
</TeleportOverflowMenu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center border-0 border-t border-solid border-surface-4 px-6 py-4"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-[var(--color-text-default)]">
|
||||||
|
{{ formatMessage(messages.serverCount, { count: syncedServers.length }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
|
||||||
|
<NewModal
|
||||||
|
ref="editServerModal"
|
||||||
|
:header="formatMessage(messages.editServerTitle)"
|
||||||
|
:disable-close="saveMutation.isPending.value"
|
||||||
|
max-width="500px"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<div v-if="editedServer" class="flex flex-col gap-4">
|
||||||
|
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{{ formatMessage(messages.serverName) }}
|
||||||
|
<StyledInput v-model="editedServer.name" autocomplete="off" wrapper-class="w-full" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{{ formatMessage(messages.serverAddress) }}
|
||||||
|
<StyledInput v-model="editedServer.address" autocomplete="off" wrapper-class="w-full" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="outlined" @click="editServerModal?.hide()">
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="brand"
|
||||||
|
:disabled="!editedServer?.address"
|
||||||
|
:loading="saveMutation.isPending.value"
|
||||||
|
@click="saveSyncedServer"
|
||||||
|
>
|
||||||
|
<SaveIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { defineMessage, type MessageDescriptor } from '@modrinth/ui'
|
import type { MessageDescriptor } from '@modrinth/ui'
|
||||||
|
|
||||||
export type SettingsCategoryId =
|
export type SettingsCategoryId =
|
||||||
| 'interface'
|
| 'interface'
|
||||||
@@ -28,100 +28,100 @@ export interface SettingsCategoryDefinition {
|
|||||||
export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
|
export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
|
||||||
{
|
{
|
||||||
id: 'interface',
|
id: 'interface',
|
||||||
name: defineMessage({
|
name: {
|
||||||
id: 'app.settings.tabs.interface',
|
id: 'app.settings.tabs.interface',
|
||||||
defaultMessage: 'Interface & appearance',
|
defaultMessage: 'Interface & appearance',
|
||||||
}),
|
},
|
||||||
group: 'launcher',
|
group: 'launcher',
|
||||||
onboardingId: 'settings-tab-interface',
|
onboardingId: 'settings-tab-interface',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'home-navigation',
|
id: 'home-navigation',
|
||||||
name: defineMessage({
|
name: {
|
||||||
id: 'app.settings.tabs.home-navigation',
|
id: 'app.settings.tabs.home-navigation',
|
||||||
defaultMessage: 'Home & navigation',
|
defaultMessage: 'Home & navigation',
|
||||||
}),
|
},
|
||||||
group: 'launcher',
|
group: 'launcher',
|
||||||
onboardingId: 'settings-tab-home-navigation',
|
onboardingId: 'settings-tab-home-navigation',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'language-translation',
|
id: 'language-translation',
|
||||||
name: defineMessage({
|
name: {
|
||||||
id: 'app.settings.tabs.language-translation',
|
id: 'app.settings.tabs.language-translation',
|
||||||
defaultMessage: 'Language & translation',
|
defaultMessage: 'Language & translation',
|
||||||
}),
|
},
|
||||||
group: 'launcher',
|
group: 'launcher',
|
||||||
onboardingId: 'settings-tab-language-translation',
|
onboardingId: 'settings-tab-language-translation',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ai',
|
id: 'ai',
|
||||||
name: defineMessage({ id: 'app.settings.tabs.ai', defaultMessage: 'AI' }),
|
name: { id: 'app.settings.tabs.ai', defaultMessage: 'AI' },
|
||||||
group: 'launcher',
|
group: 'launcher',
|
||||||
flushContent: true,
|
flushContent: true,
|
||||||
onboardingId: 'settings-tab-ai',
|
onboardingId: 'settings-tab-ai',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'java-performance',
|
id: 'java-performance',
|
||||||
name: defineMessage({
|
name: {
|
||||||
id: 'app.settings.tabs.java-performance',
|
id: 'app.settings.tabs.java-performance',
|
||||||
defaultMessage: 'Java & performance',
|
defaultMessage: 'Java & performance',
|
||||||
}),
|
},
|
||||||
group: 'game',
|
group: 'game',
|
||||||
onboardingId: 'settings-tab-java-performance',
|
onboardingId: 'settings-tab-java-performance',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'launch-defaults',
|
id: 'launch-defaults',
|
||||||
name: defineMessage({
|
name: {
|
||||||
id: 'app.settings.tabs.launch-defaults',
|
id: 'app.settings.tabs.launch-defaults',
|
||||||
defaultMessage: 'Launch & instance defaults',
|
defaultMessage: 'Launch & instance defaults',
|
||||||
}),
|
},
|
||||||
group: 'game',
|
group: 'game',
|
||||||
onboardingId: 'settings-tab-launch-defaults',
|
onboardingId: 'settings-tab-launch-defaults',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'content-downloads',
|
id: 'content-downloads',
|
||||||
name: defineMessage({
|
name: {
|
||||||
id: 'app.settings.tabs.content-downloads',
|
id: 'app.settings.tabs.content-downloads',
|
||||||
defaultMessage: 'Content & downloads',
|
defaultMessage: 'Content & downloads',
|
||||||
}),
|
},
|
||||||
group: 'game',
|
group: 'game',
|
||||||
onboardingId: 'settings-tab-content-downloads',
|
onboardingId: 'settings-tab-content-downloads',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'network-multiplayer',
|
id: 'network-multiplayer',
|
||||||
name: defineMessage({
|
name: {
|
||||||
id: 'app.settings.tabs.network-multiplayer',
|
id: 'app.settings.tabs.network-multiplayer',
|
||||||
defaultMessage: 'Network & multiplayer',
|
defaultMessage: 'Network & multiplayer',
|
||||||
}),
|
},
|
||||||
group: 'game',
|
group: 'game',
|
||||||
onboardingId: 'settings-tab-network-multiplayer',
|
onboardingId: 'settings-tab-network-multiplayer',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'storage-backups',
|
id: 'storage-backups',
|
||||||
name: defineMessage({
|
name: {
|
||||||
id: 'app.settings.tabs.storage-backups',
|
id: 'app.settings.tabs.storage-backups',
|
||||||
defaultMessage: 'Storage & backups',
|
defaultMessage: 'Storage & backups',
|
||||||
}),
|
},
|
||||||
group: 'data-privacy',
|
group: 'data-privacy',
|
||||||
onboardingId: 'settings-tab-storage-backups',
|
onboardingId: 'settings-tab-storage-backups',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'updates',
|
id: 'updates',
|
||||||
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
|
name: { id: 'app.settings.tabs.updates', defaultMessage: 'Updates' },
|
||||||
group: 'support',
|
group: 'support',
|
||||||
onboardingId: 'settings-tab-updates',
|
onboardingId: 'settings-tab-updates',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
name: defineMessage({ id: 'app.settings.tabs.about', defaultMessage: 'About' }),
|
name: { id: 'app.settings.tabs.about', defaultMessage: 'About' },
|
||||||
group: 'support',
|
group: 'support',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'feature-flags',
|
id: 'feature-flags',
|
||||||
name: defineMessage({
|
name: {
|
||||||
id: 'settings.feature-flags.title',
|
id: 'settings.feature-flags.title',
|
||||||
defaultMessage: 'Feature flags',
|
defaultMessage: 'Feature flags',
|
||||||
}),
|
},
|
||||||
group: 'developer',
|
group: 'developer',
|
||||||
developerOnly: true,
|
developerOnly: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { commonMessages, defineMessages, type MessageDescriptor } from '@modrinth/ui'
|
import { commonMessages, defineMessages, type MessageDescriptor } from '@modrinth/ui'
|
||||||
import { defineAsyncComponent, type Component } from 'vue'
|
import { type Component, defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getVisibleSettingsCategoryDefinitions,
|
getVisibleSettingsCategoryDefinitions,
|
||||||
type SettingsCategoryDefinition,
|
type SettingsCategoryDefinition,
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
|||||||
import { get_project } from '@/helpers/cache'
|
import { get_project } from '@/helpers/cache'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { kill, run } from '@/helpers/instance'
|
import { kill, run } from '@/helpers/instance'
|
||||||
import { get_by_instance_id } from '@/helpers/process'
|
|
||||||
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
||||||
|
import { get_by_instance_id } from '@/helpers/process'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import { showInstanceInFolder } from '@/helpers/utils'
|
import { showInstanceInFolder } from '@/helpers/utils'
|
||||||
import { handleSevereError } from '@/store/error'
|
import { handleSevereError } from '@/store/error'
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import assert from 'node:assert/strict'
|
|
||||||
import test from 'node:test'
|
|
||||||
|
|
||||||
import { createLatestLogReader } from './latest-log-reader.ts'
|
|
||||||
|
|
||||||
test('follows file-only output after startup, re-entry, clear and file rotation', async () => {
|
|
||||||
let file = ''
|
|
||||||
let displayed = 'stdout startup'
|
|
||||||
const reader = createLatestLogReader(
|
|
||||||
async (cursor) => ({
|
|
||||||
cursor: file.length,
|
|
||||||
output: file.slice(cursor > file.length ? 0 : cursor),
|
|
||||||
new_file: cursor > file.length,
|
|
||||||
}),
|
|
||||||
(output, replace) => {
|
|
||||||
displayed = (replace ? '' : displayed) + output
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await reader.refresh()
|
|
||||||
assert.equal(reader.active, false)
|
|
||||||
file = 'Startup complete\n'
|
|
||||||
await reader.refresh()
|
|
||||||
assert.equal(reader.active, true)
|
|
||||||
assert.equal(displayed, file) // Replaces stdout, not appended a second time.
|
|
||||||
file += 'Joined world\nDisconnected\n'
|
|
||||||
await reader.refresh()
|
|
||||||
await reader.refresh() // Re-entering the view must not replay history.
|
|
||||||
assert.equal(displayed, file)
|
|
||||||
reader.clear()
|
|
||||||
displayed = ''
|
|
||||||
file += 'After clear\n'
|
|
||||||
await reader.refresh()
|
|
||||||
assert.equal(displayed, 'After clear\n')
|
|
||||||
file = 'Rotated\n'
|
|
||||||
await reader.refresh()
|
|
||||||
assert.equal(displayed, file)
|
|
||||||
file = ''
|
|
||||||
await reader.refresh()
|
|
||||||
assert.equal(displayed, '')
|
|
||||||
assert.equal(reader.active, false)
|
|
||||||
file = 'File recreated\n'
|
|
||||||
await reader.refresh()
|
|
||||||
assert.equal(displayed, file)
|
|
||||||
reader.reset()
|
|
||||||
assert.equal(reader.active, false)
|
|
||||||
file = 'New session with a longer file\n'
|
|
||||||
await reader.refresh()
|
|
||||||
assert.equal(displayed, file)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('serializes reads, ignores pre-restart responses, consumes pre-clear responses', async () => {
|
|
||||||
const resolvers: ((value: { cursor: number; output: string; new_file: boolean }) => void)[] = []
|
|
||||||
const cursors: number[] = []
|
|
||||||
const output: string[] = []
|
|
||||||
const reader = createLatestLogReader(
|
|
||||||
(cursor) => {
|
|
||||||
cursors.push(cursor)
|
|
||||||
return new Promise((resolve) => resolvers.push(resolve))
|
|
||||||
},
|
|
||||||
(text) => output.push(text),
|
|
||||||
)
|
|
||||||
const old = reader.refresh()
|
|
||||||
const concurrent = reader.refresh()
|
|
||||||
assert.deepEqual(cursors, [0])
|
|
||||||
reader.reset()
|
|
||||||
const fresh = reader.refresh()
|
|
||||||
resolvers[0]({ cursor: 100, output: 'old session', new_file: false })
|
|
||||||
await Promise.all([old, concurrent])
|
|
||||||
assert.deepEqual(output, [])
|
|
||||||
reader.clear()
|
|
||||||
resolvers[1]({ cursor: 20, output: 'before clear', new_file: false })
|
|
||||||
await fresh
|
|
||||||
assert.deepEqual(output, [])
|
|
||||||
const next = reader.refresh()
|
|
||||||
assert.deepEqual(cursors, [0, 0, 20])
|
|
||||||
resolvers[2]({ cursor: 40, output: 'after clear\n', new_file: false })
|
|
||||||
await next
|
|
||||||
assert.deepEqual(output, ['after clear\n'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('a temporary read failure keeps the cursor and retries without duplicating output', async () => {
|
|
||||||
let failed = true
|
|
||||||
const output: string[] = []
|
|
||||||
const reader = createLatestLogReader(
|
|
||||||
async (cursor) => {
|
|
||||||
assert.equal(cursor, 0)
|
|
||||||
if (failed) throw new Error('file temporarily unavailable')
|
|
||||||
return { cursor: 6, output: 'ready\n', new_file: false }
|
|
||||||
},
|
|
||||||
(text) => output.push(text),
|
|
||||||
)
|
|
||||||
await assert.rejects(reader.refresh())
|
|
||||||
assert.equal(reader.active, false)
|
|
||||||
failed = false
|
|
||||||
await reader.refresh()
|
|
||||||
assert.deepEqual(output, ['ready\n'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('joins split lines and flushes the final unterminated line on exit', async () => {
|
|
||||||
let chunk = 'Startup\n[Render thread/INFO] Join'
|
|
||||||
let cursor = 0
|
|
||||||
let displayed = ''
|
|
||||||
const reader = createLatestLogReader(
|
|
||||||
async () => ({
|
|
||||||
cursor: (cursor += chunk.length),
|
|
||||||
output: chunk,
|
|
||||||
new_file: false,
|
|
||||||
}),
|
|
||||||
(text, replace) => {
|
|
||||||
displayed = (replace ? '' : displayed) + text
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await reader.refresh()
|
|
||||||
assert.equal(displayed, 'Startup\n')
|
|
||||||
chunk = 'ed world\nLast line'
|
|
||||||
await reader.refresh()
|
|
||||||
assert.equal(displayed, 'Startup\n[Render thread/INFO] Joined world\n')
|
|
||||||
reader.flush()
|
|
||||||
assert.equal(displayed, 'Startup\n[Render thread/INFO] Joined world\nLast line')
|
|
||||||
})
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
interface LogCursor {
|
|
||||||
cursor: number
|
|
||||||
output: string
|
|
||||||
new_file: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One authoritative stream: use process events until latest.log becomes available. */
|
|
||||||
export function createLatestLogReader(
|
|
||||||
read: (cursor: number) => Promise<LogCursor>,
|
|
||||||
append: (output: string, replace: boolean) => void,
|
|
||||||
) {
|
|
||||||
let cursor = 0
|
|
||||||
let active = false
|
|
||||||
let session = 0
|
|
||||||
let cleared = 0
|
|
||||||
let partialLine = ''
|
|
||||||
let replaceOnAppend = false
|
|
||||||
let pending: Promise<void> | null = null
|
|
||||||
|
|
||||||
function refresh(): Promise<void> {
|
|
||||||
if (pending) return pending
|
|
||||||
const requestSession = session
|
|
||||||
const requestClear = cleared
|
|
||||||
const request = (async () => {
|
|
||||||
const result = await read(cursor)
|
|
||||||
if (requestSession !== session) return
|
|
||||||
const rotated = result.new_file || result.cursor < cursor
|
|
||||||
const replace = !active || rotated
|
|
||||||
cursor = result.cursor
|
|
||||||
if (result.cursor === 0 && !result.output) {
|
|
||||||
if (rotated) {
|
|
||||||
active = false
|
|
||||||
partialLine = ''
|
|
||||||
replaceOnAppend = false
|
|
||||||
if (requestClear === cleared) append('', true)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
active = true
|
|
||||||
// Clearing during a read consumes its bytes without restoring cleared text.
|
|
||||||
if (requestClear === cleared && (result.output || result.new_file)) {
|
|
||||||
if (replace) {
|
|
||||||
partialLine = ''
|
|
||||||
replaceOnAppend = true
|
|
||||||
}
|
|
||||||
partialLine += result.output
|
|
||||||
// File writes can end mid-line. Do not turn the next chunk into a
|
|
||||||
// separate log entry; bound unterminated output just like the console.
|
|
||||||
const end =
|
|
||||||
partialLine.length > 64 * 1024 ? partialLine.length : partialLine.lastIndexOf('\n') + 1
|
|
||||||
if (end > 0) {
|
|
||||||
append(partialLine.slice(0, end), replaceOnAppend)
|
|
||||||
partialLine = partialLine.slice(end)
|
|
||||||
replaceOnAppend = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
pending = request
|
|
||||||
return request.finally(() => {
|
|
||||||
if (pending === request) pending = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
refresh,
|
|
||||||
get active() {
|
|
||||||
return active
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
cleared++
|
|
||||||
partialLine = ''
|
|
||||||
replaceOnAppend = false
|
|
||||||
},
|
|
||||||
flush() {
|
|
||||||
if (partialLine) append(partialLine, replaceOnAppend)
|
|
||||||
partialLine = ''
|
|
||||||
replaceOnAppend = false
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
session++
|
|
||||||
cursor = 0
|
|
||||||
active = false
|
|
||||||
partialLine = ''
|
|
||||||
replaceOnAppend = false
|
|
||||||
pending = null
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createLiveLogReconciler,
|
||||||
|
type LiveLogSnapshot,
|
||||||
|
} from './live-log-reconciler.ts'
|
||||||
|
|
||||||
|
test('merges the authoritative snapshot with queued events without gaps or duplicates', async () => {
|
||||||
|
let resolveSnapshot!: (snapshot: LiveLogSnapshot) => void
|
||||||
|
const displayed: number[] = []
|
||||||
|
const reconciler = createLiveLogReconciler(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSnapshot = resolve
|
||||||
|
}),
|
||||||
|
(snapshot) => {
|
||||||
|
displayed.splice(
|
||||||
|
0,
|
||||||
|
displayed.length,
|
||||||
|
...snapshot.lines.map((line) => line.sequence),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(event) => displayed.push(event.last_sequence),
|
||||||
|
)
|
||||||
|
|
||||||
|
const syncing = reconciler.sync()
|
||||||
|
reconciler.accept({ first_sequence: 3, last_sequence: 3 })
|
||||||
|
resolveSnapshot({
|
||||||
|
lines: [
|
||||||
|
{ sequence: 1, message: 'one' },
|
||||||
|
{ sequence: 2, message: 'two' },
|
||||||
|
],
|
||||||
|
last_sequence: 2,
|
||||||
|
})
|
||||||
|
await syncing
|
||||||
|
assert.deepEqual(displayed, [1, 2, 3])
|
||||||
|
|
||||||
|
await reconciler.accept({ first_sequence: 3, last_sequence: 3 })
|
||||||
|
assert.deepEqual(displayed, [1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resynchronizes when an event sequence reveals a missed batch', async () => {
|
||||||
|
const displayed: number[] = []
|
||||||
|
let loads = 0
|
||||||
|
const reconciler = createLiveLogReconciler(
|
||||||
|
async () => {
|
||||||
|
loads++
|
||||||
|
return {
|
||||||
|
lines: [1, 2, 3, 4].map((sequence) => ({
|
||||||
|
sequence,
|
||||||
|
message: String(sequence),
|
||||||
|
})),
|
||||||
|
last_sequence: 4,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(snapshot) => {
|
||||||
|
displayed.splice(
|
||||||
|
0,
|
||||||
|
displayed.length,
|
||||||
|
...snapshot.lines.map((line) => line.sequence),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(event) => displayed.push(event.last_sequence),
|
||||||
|
)
|
||||||
|
|
||||||
|
await reconciler.accept({ first_sequence: 4, last_sequence: 4 })
|
||||||
|
assert.equal(loads, 1)
|
||||||
|
assert.deepEqual(displayed, [1, 2, 3, 4])
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
export interface LiveLogSnapshot {
|
||||||
|
lines: Array<{ sequence: number; message: string }>
|
||||||
|
last_sequence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SequencedLogEvent {
|
||||||
|
first_sequence: number
|
||||||
|
last_sequence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLiveLogReconciler<T extends SequencedLogEvent>(
|
||||||
|
loadSnapshot: () => Promise<LiveLogSnapshot>,
|
||||||
|
replace: (snapshot: LiveLogSnapshot) => void,
|
||||||
|
append: (event: T) => void,
|
||||||
|
) {
|
||||||
|
let lastSequence = 0
|
||||||
|
let queued: T[] = []
|
||||||
|
let syncing: Promise<void> | null = null
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
function drainQueued(): boolean {
|
||||||
|
const events = queued
|
||||||
|
queued = []
|
||||||
|
for (let index = 0; index < events.length; index++) {
|
||||||
|
const event = events[index]!
|
||||||
|
if (event.last_sequence <= lastSequence) continue
|
||||||
|
if (event.first_sequence > lastSequence + 1) {
|
||||||
|
queued.push(...events.slice(index))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
append(event)
|
||||||
|
lastSequence = event.last_sequence
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync(): Promise<void> {
|
||||||
|
if (syncing) return syncing
|
||||||
|
const request = (async () => {
|
||||||
|
do {
|
||||||
|
const snapshot = await loadSnapshot()
|
||||||
|
if (disposed) return
|
||||||
|
replace(snapshot)
|
||||||
|
lastSequence = snapshot.last_sequence
|
||||||
|
} while (!drainQueued())
|
||||||
|
})()
|
||||||
|
const completion = request.finally(() => {
|
||||||
|
if (syncing === completion) syncing = null
|
||||||
|
})
|
||||||
|
syncing = completion
|
||||||
|
return completion
|
||||||
|
}
|
||||||
|
|
||||||
|
function resync(): Promise<void> {
|
||||||
|
return syncing ? syncing.then(sync) : sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
function accept(event: T): Promise<void> | undefined {
|
||||||
|
if (disposed || event.last_sequence <= lastSequence) return
|
||||||
|
if (syncing) {
|
||||||
|
queued.push(event)
|
||||||
|
return syncing
|
||||||
|
}
|
||||||
|
if (event.first_sequence > lastSequence + 1) {
|
||||||
|
queued.push(event)
|
||||||
|
return sync()
|
||||||
|
}
|
||||||
|
append(event)
|
||||||
|
lastSequence = event.last_sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accept,
|
||||||
|
resync,
|
||||||
|
sync,
|
||||||
|
dispose() {
|
||||||
|
disposed = true
|
||||||
|
queued = []
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { useHostedSync } from './useHostedSync.ts'
|
|
||||||
import { useHostedCreation, forgetHostedCreation } from './useHostedCreation.ts'
|
|
||||||
import {
|
import {
|
||||||
clearHostedSession,
|
clearHostedSession,
|
||||||
|
HOSTED_ACCOUNT_REQUIRED_MESSAGE,
|
||||||
hostedCreate,
|
hostedCreate,
|
||||||
hostedDefault,
|
hostedDefault,
|
||||||
hostedSync,
|
hostedSync,
|
||||||
onHostedPackAttemptStarted,
|
onHostedPackAttemptStarted,
|
||||||
setInstanceMode,
|
setInstanceMode,
|
||||||
} from '../helpers/hosted-packs.ts'
|
} from '../helpers/hosted-packs.ts'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
openSkinSiteLogin,
|
openSkinSiteLogin,
|
||||||
receiveSkinSiteMessage,
|
receiveSkinSiteMessage,
|
||||||
requestSkinSiteLuck,
|
|
||||||
requestSkinSiteDownloadToken,
|
requestSkinSiteDownloadToken,
|
||||||
|
requestSkinSiteLuck,
|
||||||
requestSkinSitePlayers,
|
requestSkinSitePlayers,
|
||||||
requestSkinSiteSkinUpdate,
|
requestSkinSiteSkinUpdate,
|
||||||
resetSkinSiteSession,
|
resetSkinSiteSession,
|
||||||
@@ -27,6 +26,8 @@ import {
|
|||||||
skinSiteStatus,
|
skinSiteStatus,
|
||||||
skinSiteUser,
|
skinSiteUser,
|
||||||
} from './skin-site-session.ts'
|
} from './skin-site-session.ts'
|
||||||
|
import { forgetHostedCreation, useHostedCreation } from './useHostedCreation.ts'
|
||||||
|
import { useHostedSync } from './useHostedSync.ts'
|
||||||
|
|
||||||
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
|
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
|
||||||
const frame = { postMessage() {} } as unknown as Window
|
const frame = { postMessage() {} } as unknown as Window
|
||||||
@@ -46,6 +47,10 @@ test('hosted installation sends the JWT to native commands while Local needs no
|
|||||||
let createdId = 'instance'
|
let createdId = 'instance'
|
||||||
let instanceExists = false
|
let instanceExists = false
|
||||||
let tokenRequests = 0
|
let tokenRequests = 0
|
||||||
|
let accounts: Array<{
|
||||||
|
account_type: string
|
||||||
|
yggdrasil?: { api_root: string }
|
||||||
|
}> = [{ account_type: 'microsoft' }]
|
||||||
const frame = {
|
const frame = {
|
||||||
postMessage(data: { type: string; requestId: string }) {
|
postMessage(data: { type: string; requestId: string }) {
|
||||||
if (data.type !== 'starlight-pack-token-request') return
|
if (data.type !== 'starlight-pack-token-request') return
|
||||||
@@ -70,6 +75,14 @@ test('hosted installation sends the JWT to native commands while Local needs no
|
|||||||
__TAURI_INTERNALS__: {
|
__TAURI_INTERNALS__: {
|
||||||
async invoke(command: string, args: Record<string, unknown>) {
|
async invoke(command: string, args: Record<string, unknown>) {
|
||||||
calls.push({ command, args })
|
calls.push({ command, args })
|
||||||
|
if (command === 'plugin:install|hosted_has_game_account')
|
||||||
|
return accounts.some(
|
||||||
|
(account) =>
|
||||||
|
account.account_type === 'microsoft' ||
|
||||||
|
(account.account_type === 'yggdrasil' &&
|
||||||
|
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
|
||||||
|
'https://skin.starlight.cool/yggdrasil'),
|
||||||
|
)
|
||||||
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
|
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
|
||||||
if (command === 'plugin:instance|instance_get')
|
if (command === 'plugin:instance|instance_get')
|
||||||
return instanceExists ? { id: args.instanceId } : null
|
return instanceExists ? { id: args.instanceId } : null
|
||||||
@@ -99,7 +112,13 @@ test('hosted installation sends the JWT to native commands while Local needs no
|
|||||||
await setInstanceMode('instance', 'starlight')
|
await setInstanceMode('instance', 'starlight')
|
||||||
assert.equal(tokenRequests, 4)
|
assert.equal(tokenRequests, 4)
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.map(({ command }) => command),
|
calls
|
||||||
|
.map(({ command }) => command)
|
||||||
|
.filter(
|
||||||
|
(command) =>
|
||||||
|
command.startsWith('plugin:install|') &&
|
||||||
|
command !== 'plugin:install|hosted_has_game_account',
|
||||||
|
),
|
||||||
[
|
[
|
||||||
'hosted_set_session',
|
'hosted_set_session',
|
||||||
'hosted_default',
|
'hosted_default',
|
||||||
@@ -210,9 +229,35 @@ test('hosted installation sends the JWT to native commands while Local needs no
|
|||||||
assert.equal(tokenRequests, requestsBeforeLogout)
|
assert.equal(tokenRequests, requestsBeforeLogout)
|
||||||
const attempts: string[] = []
|
const attempts: string[] = []
|
||||||
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
|
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
|
||||||
|
accounts = []
|
||||||
|
const installCallsBeforeAccountFailure = calls.filter(
|
||||||
|
({ command }) =>
|
||||||
|
command.startsWith('plugin:install|') &&
|
||||||
|
command !== 'plugin:install|hosted_has_game_account',
|
||||||
|
).length
|
||||||
|
await assert.rejects(hostedSync('failed-instance'), {
|
||||||
|
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
|
||||||
|
})
|
||||||
|
await assert.rejects(hostedCreate(), {
|
||||||
|
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
|
||||||
|
})
|
||||||
|
assert.deepEqual(attempts, [])
|
||||||
|
assert.equal(
|
||||||
|
calls.filter(
|
||||||
|
({ command }) =>
|
||||||
|
command.startsWith('plugin:install|') &&
|
||||||
|
command !== 'plugin:install|hosted_has_game_account',
|
||||||
|
).length,
|
||||||
|
installCallsBeforeAccountFailure,
|
||||||
|
)
|
||||||
|
const unauthenticatedCreation = useHostedCreation()
|
||||||
|
await unauthenticatedCreation.install()
|
||||||
|
assert.equal(unauthenticatedCreation.installError.value, HOSTED_ACCOUNT_REQUIRED_MESSAGE)
|
||||||
|
assert.equal(unauthenticatedCreation.createdInstance.value, undefined)
|
||||||
|
accounts = [{ account_type: 'microsoft' }]
|
||||||
const unauthenticatedRetry = hostedSync('failed-instance')
|
const unauthenticatedRetry = hostedSync('failed-instance')
|
||||||
assert.deepEqual(attempts, ['failed-instance'])
|
|
||||||
await assert.rejects(unauthenticatedRetry)
|
await assert.rejects(unauthenticatedRetry)
|
||||||
|
assert.deepEqual(attempts, ['failed-instance'])
|
||||||
stopListening()
|
stopListening()
|
||||||
await assert.rejects(hostedSync('failed-instance'))
|
await assert.rejects(hostedSync('failed-instance'))
|
||||||
assert.equal(attempts.length, 1)
|
assert.equal(attempts.length, 1)
|
||||||
@@ -560,3 +605,110 @@ test('skin updates are restricted to known non-Mojang players and validated resu
|
|||||||
setSkinSiteFrame(null)
|
setSkinSiteFrame(null)
|
||||||
resetSkinSiteSession()
|
resetSkinSiteSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('hosted create auto-authenticates the first skin site player when no game account exists', async () => {
|
||||||
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||||
|
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
|
||||||
|
let accounts: Array<{
|
||||||
|
account_type: string
|
||||||
|
yggdrasil?: { api_root: string }
|
||||||
|
}> = []
|
||||||
|
const frame = {
|
||||||
|
postMessage(data: { type: string; requestId: string }) {
|
||||||
|
if (data.type === 'starlight-pack-token-request') {
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-pack-token-result',
|
||||||
|
requestId: data.requestId,
|
||||||
|
token: 'site.jwt.secret',
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.type === 'starlight-skin-players-request') {
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-players-result',
|
||||||
|
requestId: data.requestId,
|
||||||
|
ok: true,
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
uuid: '11111111-1111-1111-1111-111111111111',
|
||||||
|
name: 'AutoPlayer',
|
||||||
|
isMojang: false,
|
||||||
|
skinState: 'empty',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as unknown as Window
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
__TAURI_INTERNALS__: {
|
||||||
|
async invoke(command: string, args: Record<string, unknown>) {
|
||||||
|
calls.push({ command, args })
|
||||||
|
if (command === 'plugin:install|hosted_has_game_account') {
|
||||||
|
return accounts.some(
|
||||||
|
(account) =>
|
||||||
|
account.account_type === 'microsoft' ||
|
||||||
|
(account.account_type === 'yggdrasil' &&
|
||||||
|
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
|
||||||
|
'https://skin.starlight.cool/yggdrasil'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (command === 'plugin:auth|login_skin_site_player') {
|
||||||
|
accounts = [
|
||||||
|
{
|
||||||
|
account_type: 'yggdrasil',
|
||||||
|
yggdrasil: { api_root: 'https://skin.starlight.cool/yggdrasil' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (command === 'plugin:instance|instance_get') return null
|
||||||
|
return command === 'plugin:install|hosted_create' ? 'auto-instance' : null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
resetSkinSiteSession()
|
||||||
|
setSkinSiteFrame(frame)
|
||||||
|
receiveSkinSiteMessage(
|
||||||
|
{
|
||||||
|
origin: SKIN_SITE_ORIGIN,
|
||||||
|
source: frame,
|
||||||
|
data: {
|
||||||
|
type: 'starlight-skin-session',
|
||||||
|
status: 'signed-in',
|
||||||
|
user: { uuid: 'site-user', username: 'Site' },
|
||||||
|
},
|
||||||
|
} as MessageEvent,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
await requestSkinSitePlayers().catch(() => {})
|
||||||
|
assert.equal(await hostedCreate(), 'auto-instance')
|
||||||
|
assert.ok(
|
||||||
|
calls.some(({ command }) => command === 'plugin:auth|login_skin_site_player'),
|
||||||
|
'expected automatic player authentication',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
resetSkinSiteSession()
|
||||||
|
setSkinSiteFrame(null)
|
||||||
|
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
|
||||||
|
else delete (globalThis as { window?: unknown }).window
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
import { instance_listener } from '@/helpers/events.js'
|
||||||
|
|
||||||
|
export interface InstanceAppEvent {
|
||||||
|
event: string
|
||||||
|
instance_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppEvent(type: 'instance', callback: (event: InstanceAppEvent) => void) {
|
||||||
|
if (type !== 'instance') return
|
||||||
|
let unlisten: (() => void) | undefined
|
||||||
|
void instance_listener(callback).then((dispose) => {
|
||||||
|
unlisten = dispose
|
||||||
|
})
|
||||||
|
onUnmounted(() => unlisten?.())
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||||
|
import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
|
||||||
|
|
||||||
|
export type ImageThumbnailSource = {
|
||||||
|
instanceId: string
|
||||||
|
filePath: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThumbnailCacheEntry = {
|
||||||
|
promise: Promise<string>
|
||||||
|
url?: string
|
||||||
|
users: number
|
||||||
|
lastUsed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CONCURRENT_THUMBNAILS = 4
|
||||||
|
const MAX_CACHED_THUMBNAILS = 256
|
||||||
|
|
||||||
|
const thumbnailCache = new Map<string, ThumbnailCacheEntry>()
|
||||||
|
const thumbnailQueue: Array<() => void> = []
|
||||||
|
let activeThumbnailRequests = 0
|
||||||
|
|
||||||
|
function runThumbnailQueue() {
|
||||||
|
while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) {
|
||||||
|
activeThumbnailRequests += 1
|
||||||
|
thumbnailQueue.shift()?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueThumbnail<T>(task: () => Promise<T>) {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
thumbnailQueue.push(() => {
|
||||||
|
void task()
|
||||||
|
.then(resolve, reject)
|
||||||
|
.finally(() => {
|
||||||
|
activeThumbnailRequests -= 1
|
||||||
|
runThumbnailQueue()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
runThumbnailQueue()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) {
|
||||||
|
if (thumbnailCache.get(cacheKey) !== entry) return
|
||||||
|
thumbnailCache.delete(cacheKey)
|
||||||
|
if (entry.url) {
|
||||||
|
URL.revokeObjectURL(entry.url)
|
||||||
|
} else {
|
||||||
|
void entry.promise.then(
|
||||||
|
(url) => URL.revokeObjectURL(url),
|
||||||
|
() => undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimThumbnailCache() {
|
||||||
|
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return
|
||||||
|
|
||||||
|
const unused = [...thumbnailCache.entries()]
|
||||||
|
.filter(([, entry]) => entry.users === 0)
|
||||||
|
.sort(([, left], [, right]) => left.lastUsed - right.lastUsed)
|
||||||
|
for (const [cacheKey, entry] of unused) {
|
||||||
|
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break
|
||||||
|
removeThumbnail(cacheKey, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) {
|
||||||
|
const cacheKey = JSON.stringify([
|
||||||
|
source.instanceId,
|
||||||
|
source.filePath,
|
||||||
|
maxDimension,
|
||||||
|
revision ?? null,
|
||||||
|
])
|
||||||
|
let entry = thumbnailCache.get(cacheKey)
|
||||||
|
if (!entry) {
|
||||||
|
entry = {
|
||||||
|
users: 0,
|
||||||
|
lastUsed: performance.now(),
|
||||||
|
promise: enqueueThumbnail(async () => {
|
||||||
|
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
|
||||||
|
instanceId: source.instanceId,
|
||||||
|
filePath: source.filePath,
|
||||||
|
maxDimension,
|
||||||
|
})
|
||||||
|
return URL.createObjectURL(new Blob([bytes]))
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
thumbnailCache.set(cacheKey, entry)
|
||||||
|
void entry.promise.then(
|
||||||
|
(url) => {
|
||||||
|
entry!.url = url
|
||||||
|
trimThumbnailCache()
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.users += 1
|
||||||
|
entry.lastUsed = performance.now()
|
||||||
|
return {
|
||||||
|
promise: entry.promise,
|
||||||
|
release: () => {
|
||||||
|
entry!.users = Math.max(0, entry!.users - 1)
|
||||||
|
entry!.lastUsed = performance.now()
|
||||||
|
trimThumbnailCache()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImageThumbnail(
|
||||||
|
source: MaybeRefOrGetter<ImageThumbnailSource | null | undefined>,
|
||||||
|
maxDimension: MaybeRefOrGetter<number> = 512,
|
||||||
|
revision: MaybeRefOrGetter<string | number | undefined> = undefined,
|
||||||
|
) {
|
||||||
|
const thumbnail = ref('')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => toValue(source), () => toValue(maxDimension), () => toValue(revision)],
|
||||||
|
([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => {
|
||||||
|
thumbnail.value = ''
|
||||||
|
if (!currentSource) return
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
const fallbackUrl = convertFileSrc(currentSource.path)
|
||||||
|
const acquired = acquireThumbnail(
|
||||||
|
currentSource,
|
||||||
|
Math.max(1, Math.round(currentMaxDimension)),
|
||||||
|
currentRevision,
|
||||||
|
)
|
||||||
|
onCleanup(() => {
|
||||||
|
active = false
|
||||||
|
acquired.release()
|
||||||
|
})
|
||||||
|
|
||||||
|
void acquired.promise.then(
|
||||||
|
(url) => {
|
||||||
|
if (active) thumbnail.value = url
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.warn('Could not create image thumbnail', error)
|
||||||
|
if (active) thumbnail.value = fallbackUrl
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
return thumbnail
|
||||||
|
}
|
||||||
@@ -13,9 +13,9 @@ import type {
|
|||||||
SymlinkMethodChoice,
|
SymlinkMethodChoice,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useDebugLogger, useGlobalDrop, useInstanceContext, useVIntl } from '@modrinth/ui'
|
import { useDebugLogger, useGlobalDrop, useInstanceContext, useVIntl } from '@modrinth/ui'
|
||||||
|
import { join } from '@tauri-apps/api/path'
|
||||||
import { computed, type ComputedRef, nextTick, ref } from 'vue'
|
import { computed, type ComputedRef, nextTick, ref } from 'vue'
|
||||||
import type { Router } from 'vue-router'
|
import type { Router } from 'vue-router'
|
||||||
import { join } from '@tauri-apps/api/path'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
classifyDroppedItem,
|
classifyDroppedItem,
|
||||||
@@ -1412,10 +1412,7 @@ export function useDropImport(options: DropImportOptions) {
|
|||||||
cleanupLauncherZipTemp()
|
cleanupLauncherZipTemp()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvedInstancePath(
|
function resolvedInstancePath(inst: SelectedInstance): string | undefined {
|
||||||
inst: SelectedInstance,
|
|
||||||
ctx: ImportContext | null,
|
|
||||||
): string | undefined {
|
|
||||||
if (inst.compatibleMode) return inst.versionPath
|
if (inst.compatibleMode) return inst.versionPath
|
||||||
return inst.path
|
return inst.path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const createdInstance = ref<string>()
|
|||||||
const completed = ref(false)
|
const completed = ref(false)
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
|
||||||
|
function errorMessage(cause: unknown) {
|
||||||
|
return cause instanceof Error ? cause.message : String(cause)
|
||||||
|
}
|
||||||
|
|
||||||
export function forgetHostedCreation(instanceId: string) {
|
export function forgetHostedCreation(instanceId: string) {
|
||||||
if (createdInstance.value !== instanceId) return
|
if (createdInstance.value !== instanceId) return
|
||||||
generation++
|
generation++
|
||||||
@@ -27,7 +31,7 @@ export function markHostedCreationCompleted(instanceId: string) {
|
|||||||
export function markHostedCreationFailed(instanceId: string, cause: unknown) {
|
export function markHostedCreationFailed(instanceId: string, cause: unknown) {
|
||||||
if (createdInstance.value !== instanceId) return
|
if (createdInstance.value !== instanceId) return
|
||||||
completed.value = false
|
completed.value = false
|
||||||
installError.value = String(cause)
|
installError.value = errorMessage(cause)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHostedCreation() {
|
export function useHostedCreation() {
|
||||||
@@ -63,7 +67,7 @@ export function useHostedCreation() {
|
|||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (attempt === generation) {
|
if (attempt === generation) {
|
||||||
if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause)
|
if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause)
|
||||||
else installError.value = String(cause)
|
else installError.value = errorMessage(cause)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
installing.value = false
|
installing.value = false
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { createConsoleState } from '@modrinth/ui'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
clear_log_buffer,
|
clear_log_buffer,
|
||||||
get_live_log_buffer,
|
get_live_log_snapshot,
|
||||||
get_logs,
|
get_logs,
|
||||||
get_minecraft_latest_log_cursor,
|
|
||||||
} from '@/helpers/logs'
|
} from '@/helpers/logs'
|
||||||
|
|
||||||
import { createLatestLogReader } from './latest-log-reader'
|
import {
|
||||||
|
createLiveLogReconciler,
|
||||||
|
type SequencedLogEvent,
|
||||||
|
} from './live-log-reconciler'
|
||||||
|
|
||||||
type ConsoleState = ReturnType<typeof createConsoleState>
|
type ConsoleState = ReturnType<typeof createConsoleState>
|
||||||
|
|
||||||
@@ -25,8 +27,17 @@ interface InstanceConsoleEntry {
|
|||||||
historicalConsole: ConsoleState
|
historicalConsole: ConsoleState
|
||||||
historicalCache: Map<string, string>
|
historicalCache: Map<string, string>
|
||||||
logList: LogEntry[] | null
|
logList: LogEntry[] | null
|
||||||
liveHistoryHydration: Promise<void> | null
|
liveLog: ReturnType<typeof createLiveLogReconciler<ProcessLogPayload>>
|
||||||
latestLog: ReturnType<typeof createLatestLogReader>
|
}
|
||||||
|
|
||||||
|
interface ProcessLogPayload extends SequencedLogEvent {
|
||||||
|
type: 'log4j' | 'legacy'
|
||||||
|
message?: string
|
||||||
|
logger_name?: string
|
||||||
|
level?: string
|
||||||
|
thread_name?: string
|
||||||
|
timestamp_millis?: number
|
||||||
|
throwable?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const instances = new Map<string, InstanceConsoleEntry>()
|
const instances = new Map<string, InstanceConsoleEntry>()
|
||||||
@@ -36,19 +47,28 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
|
|||||||
if (entry) return entry
|
if (entry) return entry
|
||||||
|
|
||||||
const liveConsole = createConsoleState()
|
const liveConsole = createConsoleState()
|
||||||
|
const liveLog = createLiveLogReconciler<ProcessLogPayload>(
|
||||||
|
() => get_live_log_snapshot(instanceId),
|
||||||
|
(snapshot) => {
|
||||||
|
liveConsole.clear()
|
||||||
|
void liveConsole.addLegacyLog(
|
||||||
|
snapshot.lines.map((line) => line.message).join('\n'),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
if (payload.type === 'log4j') {
|
||||||
|
liveConsole.addLog4jEvent(payload)
|
||||||
|
} else if (payload.message !== undefined) {
|
||||||
|
void liveConsole.addLegacyLog(payload.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
entry = {
|
entry = {
|
||||||
liveConsole,
|
liveConsole,
|
||||||
historicalConsole: createConsoleState(),
|
historicalConsole: createConsoleState(),
|
||||||
historicalCache: new Map(),
|
historicalCache: new Map(),
|
||||||
logList: null,
|
logList: null,
|
||||||
liveHistoryHydration: null,
|
liveLog,
|
||||||
latestLog: createLatestLogReader(
|
|
||||||
(cursor) => get_minecraft_latest_log_cursor(instanceId, cursor),
|
|
||||||
(output, replace) => {
|
|
||||||
if (replace) liveConsole.clear()
|
|
||||||
void liveConsole.addLegacyLog(output)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
instances.set(instanceId, entry)
|
instances.set(instanceId, entry)
|
||||||
return entry
|
return entry
|
||||||
@@ -56,28 +76,7 @@ function getOrCreate(instanceId: string): InstanceConsoleEntry {
|
|||||||
|
|
||||||
async function hydrate(instanceId: string): Promise<void> {
|
async function hydrate(instanceId: string): Promise<void> {
|
||||||
const entry = getOrCreate(instanceId)
|
const entry = getOrCreate(instanceId)
|
||||||
if (entry.liveHistoryHydration) {
|
await entry.liveLog.sync()
|
||||||
return entry.liveHistoryHydration
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydration = (async () => {
|
|
||||||
await entry.latestLog.refresh().catch(console.warn)
|
|
||||||
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
|
|
||||||
const buffer = await get_live_log_buffer(instanceId)
|
|
||||||
if (!entry.latestLog.active && entry.liveConsole.output.value.length === 0) {
|
|
||||||
await entry.liveConsole.addLegacyLog(buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
entry.liveHistoryHydration = hydration
|
|
||||||
try {
|
|
||||||
await hydration
|
|
||||||
} finally {
|
|
||||||
if (entry.liveHistoryHydration === hydration) {
|
|
||||||
entry.liveHistoryHydration = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> {
|
async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> {
|
||||||
@@ -109,15 +108,13 @@ function invalidate(instanceId: string): void {
|
|||||||
|
|
||||||
async function clearLive(instanceId: string): Promise<void> {
|
async function clearLive(instanceId: string): Promise<void> {
|
||||||
const entry = getOrCreate(instanceId)
|
const entry = getOrCreate(instanceId)
|
||||||
entry.latestLog.clear()
|
await clear_log_buffer(instanceId)
|
||||||
entry.liveConsole.clear()
|
await entry.liveLog.resync()
|
||||||
await clear_log_buffer(instanceId).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function destroy(instanceId: string): Promise<void> {
|
function destroy(instanceId: string): void {
|
||||||
instances.get(instanceId)?.latestLog.reset()
|
instances.get(instanceId)?.liveLog.dispose()
|
||||||
instances.delete(instanceId)
|
instances.delete(instanceId)
|
||||||
await clear_log_buffer(instanceId).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInstanceConsole(instanceId: string) {
|
export function useInstanceConsole(instanceId: string) {
|
||||||
@@ -130,13 +127,8 @@ export function useInstanceConsole(instanceId: string) {
|
|||||||
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
|
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
|
||||||
invalidate: () => invalidate(instanceId),
|
invalidate: () => invalidate(instanceId),
|
||||||
clearLive: () => clearLive(instanceId),
|
clearLive: () => clearLive(instanceId),
|
||||||
refreshLive: () => entry.latestLog.refresh(),
|
appendLive: (payload: ProcessLogPayload) => entry.liveLog.accept(payload),
|
||||||
flushLive: () => entry.latestLog.flush(),
|
resyncLive: () => entry.liveLog.resync(),
|
||||||
acceptsProcessLogs: () => !entry.latestLog.active,
|
|
||||||
resetLive: () => {
|
|
||||||
entry.latestLog.reset()
|
|
||||||
entry.liveConsole.clear()
|
|
||||||
},
|
|
||||||
destroy: () => destroy(instanceId),
|
destroy: () => destroy(instanceId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,8 @@ export const AxolotlBrandConfig = Object.freeze({
|
|||||||
productName: 'Starlight Launcher',
|
productName: 'Starlight Launcher',
|
||||||
shortProductName: 'Starlight',
|
shortProductName: 'Starlight',
|
||||||
website: 'https://skin.starlight.cool/',
|
website: 'https://skin.starlight.cool/',
|
||||||
repositoryUrl: 'https://git.starlight.cool/Disy/StarLight-Yggdrasil-Server',
|
repositoryUrl: 'https://github.com/ApTx2012/Starlight_Lancher',
|
||||||
|
releasesUrl: 'https://github.com/ApTx2012/Starlight_Lancher/releases',
|
||||||
supportUrl: 'https://skin.starlight.cool/',
|
supportUrl: 'https://skin.starlight.cool/',
|
||||||
qqGroupNumber: '000000000',
|
qqGroupNumber: '000000000',
|
||||||
qqChannelUrl: 'https://skin.starlight.cool/',
|
qqChannelUrl: 'https://skin.starlight.cool/',
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Font family plumbing shared by the appearance settings pickers and the theme
|
||||||
|
* store. Kept free of imports so `node --test` can load it directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Mirrors `--font-standard` in `packages/assets/styles/defaults.scss`. */
|
||||||
|
export const DEFAULT_UI_FONT_STACK =
|
||||||
|
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif'
|
||||||
|
|
||||||
|
/** Mirrors `--mono-font` in `packages/assets/styles/defaults.scss`. */
|
||||||
|
export const DEFAULT_MONO_FONT_STACK =
|
||||||
|
'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
|
||||||
|
|
||||||
|
/** Picker value for "follow the launcher default"; persisted as `null`. */
|
||||||
|
export const FOLLOW_DEFAULT_FONT = ''
|
||||||
|
|
||||||
|
export interface SystemFontFamily {
|
||||||
|
family: string
|
||||||
|
monospaced: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FontFamilyOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
subLabel?: string
|
||||||
|
searchTerms?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FontFamilyOptionOrDivider = FontFamilyOption | { type: 'divider' }
|
||||||
|
|
||||||
|
export interface FontFamilyOptionsConfig {
|
||||||
|
defaultLabel: string
|
||||||
|
selected?: string | null
|
||||||
|
missingLabel?: string
|
||||||
|
monospaceLabel?: string
|
||||||
|
groupMonospaced?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FONT_FAMILY_LENGTH = 128
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quotes and escapes an installed family name so it cannot break out of the
|
||||||
|
* font-family declaration it is spliced into. Returns null when the name has
|
||||||
|
* nothing usable left.
|
||||||
|
*/
|
||||||
|
export function cssFontFamilyName(name: string): string | null {
|
||||||
|
const trimmed = name.replace(/\p{Cc}/gu, '').trim()
|
||||||
|
|
||||||
|
if (!trimmed || trimmed.length > MAX_FONT_FAMILY_LENGTH) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return `"${trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value written into `--font-standard` / `--mono-font`. The launcher stack
|
||||||
|
* always trails the chosen family so an unresolvable name degrades to the
|
||||||
|
* previous default instead of the user agent's own font.
|
||||||
|
*/
|
||||||
|
export function resolveFontFamily(
|
||||||
|
setting: string | null | undefined,
|
||||||
|
fallbackStack: string,
|
||||||
|
): string {
|
||||||
|
if (!setting) {
|
||||||
|
return fallbackStack
|
||||||
|
}
|
||||||
|
|
||||||
|
const family = cssFontFamilyName(setting)
|
||||||
|
|
||||||
|
return family ? `${family}, ${fallbackStack}` : fallbackStack
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fontSettingToOptionValue(setting: string | null | undefined): string {
|
||||||
|
return setting ?? FOLLOW_DEFAULT_FONT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionValueToFontSetting(value: string): string | null {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
|
||||||
|
return trimmed === FOLLOW_DEFAULT_FONT ? null : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareFamilies(first: SystemFontFamily, second: SystemFontFamily): number {
|
||||||
|
const left = first.family.toLowerCase()
|
||||||
|
const right = second.family.toLowerCase()
|
||||||
|
|
||||||
|
if (left === right) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return left < right ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOption(family: SystemFontFamily, monospaceLabel?: string): FontFamilyOption {
|
||||||
|
return {
|
||||||
|
value: family.family,
|
||||||
|
label: family.family,
|
||||||
|
...(family.monospaced && monospaceLabel
|
||||||
|
? { subLabel: monospaceLabel, searchTerms: [monospaceLabel] }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spelling the font list uses for a stored family. Font names are matched
|
||||||
|
* case-insensitively so a stored value that only differs in case still selects
|
||||||
|
* its option instead of leaving the picker blank.
|
||||||
|
*/
|
||||||
|
export function canonicalFontFamily(
|
||||||
|
fonts: SystemFontFamily[],
|
||||||
|
setting: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
const selected = optionValueToFontSetting(setting ?? '')
|
||||||
|
if (!selected) {
|
||||||
|
return FOLLOW_DEFAULT_FONT
|
||||||
|
}
|
||||||
|
|
||||||
|
const listed = fonts.find((font) => font.family.trim().toLowerCase() === selected.toLowerCase())
|
||||||
|
|
||||||
|
return listed ? listed.family.trim() : selected
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the picker options for the installed font collection: the default
|
||||||
|
* entry first, the current selection re-added when it is no longer installed,
|
||||||
|
* and — for the monospace picker — monospaced families ahead of the rest.
|
||||||
|
*/
|
||||||
|
export function buildFontFamilyOptions(
|
||||||
|
fonts: SystemFontFamily[],
|
||||||
|
config: FontFamilyOptionsConfig,
|
||||||
|
): FontFamilyOptionOrDivider[] {
|
||||||
|
const families = new Map<string, SystemFontFamily>()
|
||||||
|
for (const font of fonts) {
|
||||||
|
const family = font.family.trim()
|
||||||
|
|
||||||
|
if (!family) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = family.toLowerCase()
|
||||||
|
const known = families.get(key)
|
||||||
|
|
||||||
|
if (known) {
|
||||||
|
known.monospaced ||= font.monospaced
|
||||||
|
} else {
|
||||||
|
families.set(key, { family, monospaced: font.monospaced })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...families.values()].sort(compareFamilies)
|
||||||
|
const selected = optionValueToFontSetting(config.selected ?? '')
|
||||||
|
const options: FontFamilyOptionOrDivider[] = [
|
||||||
|
{ value: FOLLOW_DEFAULT_FONT, label: config.defaultLabel },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (selected && !families.has(selected.toLowerCase()) && config.missingLabel) {
|
||||||
|
options.push({ value: selected, label: selected, subLabel: config.missingLabel })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.groupMonospaced) {
|
||||||
|
const monospaced = sorted.filter((font) => font.monospaced)
|
||||||
|
const proportional = sorted.filter((font) => !font.monospaced)
|
||||||
|
|
||||||
|
options.push(...monospaced.map((font) => toOption(font, config.monospaceLabel)))
|
||||||
|
|
||||||
|
if (monospaced.length > 0 && proportional.length > 0) {
|
||||||
|
options.push({ type: 'divider' })
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push(...proportional.map((font) => toOption(font, config.monospaceLabel)))
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push(...sorted.map((font) => toOption(font)))
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { queryOptions } from '@tanstack/vue-query'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
|
export type GameOptionCanonicalValue =
|
||||||
|
| { type: 'bool'; value: boolean }
|
||||||
|
| { type: 'integer'; value: number }
|
||||||
|
| { type: 'decimal'; value: string }
|
||||||
|
| { type: 'enum'; value: string }
|
||||||
|
| { type: 'text'; value: string }
|
||||||
|
| { type: 'string_list'; value: string[] }
|
||||||
|
| { type: 'key_binding'; value: string }
|
||||||
|
| { type: 'external_raw'; value: string }
|
||||||
|
|
||||||
|
export type GameOptionValueState = 'canonical' | 'uniform_local' | 'mixed' | 'unset' | 'invalid'
|
||||||
|
|
||||||
|
export type GameOptionCompatibilityStatus =
|
||||||
|
| 'ready'
|
||||||
|
| 'deferred'
|
||||||
|
| 'waiting_for_file'
|
||||||
|
| 'waiting_for_base'
|
||||||
|
| 'not_available'
|
||||||
|
| 'unsupported_value'
|
||||||
|
| 'unmappable'
|
||||||
|
| 'catalog_uncovered'
|
||||||
|
| 'controlled'
|
||||||
|
| 'degraded'
|
||||||
|
|
||||||
|
export type GameOptionMappingKind = 'direct' | 'legacy' | 'migrated' | 'lossless' | 'lossy'
|
||||||
|
|
||||||
|
export type GameOptionCompatibilityReason =
|
||||||
|
| 'launcher_controlled'
|
||||||
|
| 'catalog_uncovered'
|
||||||
|
| 'inspection_failed'
|
||||||
|
| 'waiting_for_options_file'
|
||||||
|
| 'unsupported_value'
|
||||||
|
| 'migrates_on_write'
|
||||||
|
| 'waiting_for_compatible_base'
|
||||||
|
| 'missing_setting'
|
||||||
|
|
||||||
|
export type GameOptionCompatibilityBucket = {
|
||||||
|
instance_count: number
|
||||||
|
write_keys: string[]
|
||||||
|
eventual_keys: string[]
|
||||||
|
game_versions: string[]
|
||||||
|
status: GameOptionCompatibilityStatus
|
||||||
|
mapping?: GameOptionMappingKind | null
|
||||||
|
reason?: GameOptionCompatibilityReason | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameOptionCompatibilitySummary = {
|
||||||
|
total_participating: number
|
||||||
|
will_receive: number
|
||||||
|
write_now: number
|
||||||
|
left_local: number
|
||||||
|
buckets: GameOptionCompatibilityBucket[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameOptionEditorChoice = {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameOptionEditor = {
|
||||||
|
type:
|
||||||
|
| 'bool'
|
||||||
|
| 'integer'
|
||||||
|
| 'decimal'
|
||||||
|
| 'enum'
|
||||||
|
| 'text'
|
||||||
|
| 'string_list'
|
||||||
|
| 'key_binding'
|
||||||
|
| 'external_raw'
|
||||||
|
min?: number | null
|
||||||
|
max?: number | null
|
||||||
|
step?: number | null
|
||||||
|
unit?: string | null
|
||||||
|
choices?: GameOptionEditorChoice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameSettingCategory = {
|
||||||
|
id: string
|
||||||
|
is_custom?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameOptionValidationError =
|
||||||
|
'missing_value' | 'no_compatible_instances' | 'invalid_value' | 'changed_since_opened'
|
||||||
|
|
||||||
|
export type EditableGameSetting = {
|
||||||
|
option_id: string
|
||||||
|
category_id: string
|
||||||
|
kind: 'vanilla' | 'external'
|
||||||
|
raw_key?: string | null
|
||||||
|
sync_enabled: boolean
|
||||||
|
canonical_value?: GameOptionCanonicalValue | null
|
||||||
|
value_state: GameOptionValueState
|
||||||
|
option_revision: number
|
||||||
|
editor: GameOptionEditor
|
||||||
|
compatibility: GameOptionCompatibilitySummary
|
||||||
|
validation_error?: GameOptionValidationError | null
|
||||||
|
controlled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameSettingsEditorState = {
|
||||||
|
summary_revision: string
|
||||||
|
canonical_revision: number
|
||||||
|
catalog_revision: number
|
||||||
|
total_participating: number
|
||||||
|
categories: GameSettingCategory[]
|
||||||
|
settings: EditableGameSetting[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameSettingChange = {
|
||||||
|
option_id: string
|
||||||
|
base_option_revision: number
|
||||||
|
sync_enabled?: boolean
|
||||||
|
canonical_value?: GameOptionCanonicalValue | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateGameSettingsRequest = {
|
||||||
|
expected_summary_revision: string
|
||||||
|
expected_canonical_revision: number
|
||||||
|
expected_catalog_revision: number
|
||||||
|
changes: GameSettingChange[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameOptionsSourceCandidate = {
|
||||||
|
source_id: string
|
||||||
|
instance_id: string
|
||||||
|
name: string
|
||||||
|
icon_path?: string | null
|
||||||
|
game_version?: string | null
|
||||||
|
eligible: boolean
|
||||||
|
disabled_reason?: GameOptionsSourceDisabledReason | null
|
||||||
|
recognized_setting_count: number
|
||||||
|
custom_setting_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameOptionsSourceDisabledReason =
|
||||||
|
| 'installing_or_updating'
|
||||||
|
| 'unsupported_version'
|
||||||
|
| 'missing_options_file'
|
||||||
|
| 'no_syncable_settings'
|
||||||
|
| 'unreadable_options_file'
|
||||||
|
|
||||||
|
export type SaveGameSettingsResult = {
|
||||||
|
state?: GameSettingsEditorState
|
||||||
|
applied: number
|
||||||
|
migrated: number
|
||||||
|
deferred: number
|
||||||
|
unsupported: number
|
||||||
|
failed: number
|
||||||
|
conflicts?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameSettingLocaleLabel = {
|
||||||
|
label: string
|
||||||
|
choices: Record<string, string>
|
||||||
|
source?: GameSettingSource | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameSettingSource = {
|
||||||
|
instance_id: string
|
||||||
|
file_name: string
|
||||||
|
file_path: string
|
||||||
|
project: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon_url: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_game_setting_locale_labels(
|
||||||
|
instanceId: string | undefined,
|
||||||
|
locale: string,
|
||||||
|
optionIds: string[],
|
||||||
|
refreshSources = false,
|
||||||
|
): Promise<{ settings: Record<string, GameSettingLocaleLabel> }> {
|
||||||
|
return await invoke('plugin:instance|instance_get_game_setting_locale_labels', {
|
||||||
|
instanceId,
|
||||||
|
locale,
|
||||||
|
optionIds,
|
||||||
|
refreshSources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list_game_options_sync_sources(): Promise<GameOptionsSourceCandidate[]> {
|
||||||
|
return await invoke('plugin:instance|instance_list_game_options_sync_sources')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_synced_game_options_config(): Promise<GameSettingsEditorState> {
|
||||||
|
return await invoke('plugin:instance|instance_get_synced_game_options_config')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameSettingsKeys = {
|
||||||
|
synced: ['game-settings', 'synced'] as const,
|
||||||
|
local: (instanceId: string) => ['game-settings', 'local', instanceId] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gameSettingsQueryOptions(instanceId?: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: instanceId ? gameSettingsKeys.local(instanceId) : gameSettingsKeys.synced,
|
||||||
|
queryFn: () =>
|
||||||
|
instanceId ? get_local_game_options_config(instanceId) : get_synced_game_options_config(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preview_synced_game_option_changes(
|
||||||
|
request: UpdateGameSettingsRequest,
|
||||||
|
): Promise<GameSettingsEditorState> {
|
||||||
|
return await invoke('plugin:instance|instance_preview_synced_game_option_changes', { request })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function save_synced_game_option_changes(
|
||||||
|
request: UpdateGameSettingsRequest,
|
||||||
|
): Promise<SaveGameSettingsResult> {
|
||||||
|
return await invoke('plugin:instance|instance_save_synced_game_option_changes', { request })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_local_game_options_config(
|
||||||
|
instanceId: string,
|
||||||
|
): Promise<GameSettingsEditorState> {
|
||||||
|
return await invoke('plugin:instance|instance_get_local_game_options_config', { instanceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preview_local_game_option_changes(
|
||||||
|
instanceId: string,
|
||||||
|
request: UpdateGameSettingsRequest,
|
||||||
|
): Promise<GameSettingsEditorState> {
|
||||||
|
return await invoke('plugin:instance|instance_preview_local_game_option_changes', {
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function save_local_game_option_changes(
|
||||||
|
instanceId: string,
|
||||||
|
request: UpdateGameSettingsRequest,
|
||||||
|
): Promise<SaveGameSettingsResult> {
|
||||||
|
return await invoke('plugin:instance|instance_save_local_game_option_changes', {
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
|
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
|
||||||
|
|
||||||
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
|
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
requestSkinSiteDownloadToken,
|
requestSkinSiteDownloadToken,
|
||||||
|
requestSkinSitePlayers,
|
||||||
|
skinSitePlayers,
|
||||||
skinSiteStatus,
|
skinSiteStatus,
|
||||||
skinSiteUser,
|
skinSiteUser,
|
||||||
waitForSkinSiteSession,
|
waitForSkinSiteSession,
|
||||||
} from '../composables/skin-site-session.ts'
|
} from '../composables/skin-site-session.ts'
|
||||||
|
import { authenticateInstancePlayer } from './instance-player.ts'
|
||||||
|
|
||||||
|
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
|
||||||
|
|
||||||
let sessionUpdate: Promise<void> = Promise.resolve()
|
let sessionUpdate: Promise<void> = Promise.resolve()
|
||||||
const attemptListeners = new Set<(instanceId: string) => void>()
|
const attemptListeners = new Set<(instanceId: string) => void>()
|
||||||
@@ -20,6 +26,39 @@ function startHostedPackAttempt(instanceId: string) {
|
|||||||
for (const listener of attemptListeners) listener(instanceId)
|
for (const listener of attemptListeners) listener(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requireHostedGameAccount(): Promise<void> {
|
||||||
|
if (await invokeHosted<boolean>('plugin:install|hosted_has_game_account')) return
|
||||||
|
|
||||||
|
// No game credential yet, but a signed-in skin site already lists the
|
||||||
|
// user's players. Authenticate the first one automatically so the user
|
||||||
|
// does not have to pick a player manually before creating an instance.
|
||||||
|
await waitForSkinSiteSession()
|
||||||
|
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
|
||||||
|
if (skinSitePlayers.value.length === 0) {
|
||||||
|
await requestSkinSitePlayers().catch(() => {})
|
||||||
|
}
|
||||||
|
const player =
|
||||||
|
skinSitePlayers.value.find((candidate) => !candidate.isMojang) ?? skinSitePlayers.value[0]
|
||||||
|
if (player) {
|
||||||
|
try {
|
||||||
|
await authenticateInstancePlayer({
|
||||||
|
id: player.uuid,
|
||||||
|
name: player.name,
|
||||||
|
account_type: 'yggdrasil',
|
||||||
|
skin_site_user: skinSiteUser.value.uuid,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Fall through to the login-required error below.
|
||||||
|
}
|
||||||
|
if (await invokeHosted<boolean>('plugin:install|hosted_has_game_account')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(HOSTED_ACCOUNT_REQUIRED_MESSAGE)
|
||||||
|
}
|
||||||
|
|
||||||
export function clearHostedSession(): Promise<void> {
|
export function clearHostedSession(): Promise<void> {
|
||||||
const update = sessionUpdate
|
const update = sessionUpdate
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -99,13 +138,16 @@ export interface HostedSyncResult {
|
|||||||
}
|
}
|
||||||
export const hostedDefault = () =>
|
export const hostedDefault = () =>
|
||||||
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
|
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
|
||||||
export const hostedCreate = (gameDirRoot?: string | null) =>
|
export const hostedCreate = async (gameDirRoot?: string | null) => {
|
||||||
invokeWithSession<string>('plugin:install|hosted_create', {
|
await requireHostedGameAccount()
|
||||||
|
return invokeWithSession<string>('plugin:install|hosted_create', {
|
||||||
gameDirRoot: gameDirRoot ?? null,
|
gameDirRoot: gameDirRoot ?? null,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
export const hostedBinding = (instanceId: string) =>
|
export const hostedBinding = (instanceId: string) =>
|
||||||
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
|
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
|
||||||
export const hostedSync = (instanceId: string) => {
|
export const hostedSync = async (instanceId: string) => {
|
||||||
|
await requireHostedGameAccount()
|
||||||
startHostedPackAttempt(instanceId)
|
startHostedPackAttempt(instanceId)
|
||||||
return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId })
|
return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId })
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { canonicalizeBackupExclusions, formatBackupExclusionPath } from './instance-backup.ts'
|
||||||
|
|
||||||
|
test('directory exclusions display with a trailing slash', () => {
|
||||||
|
assert.equal(formatBackupExclusionPath({ path: 'caches', kind: 'directory' }), 'caches/')
|
||||||
|
assert.equal(
|
||||||
|
formatBackupExclusionPath({ path: 'config/somemod/bigdb.sqlite', kind: 'file' }),
|
||||||
|
'config/somemod/bigdb.sqlite',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('directory exclusions cover descendants while file exclusions stay exact', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
canonicalizeBackupExclusions([
|
||||||
|
{ path: 'saves/world', kind: 'directory' },
|
||||||
|
{ path: 'config\\bigdb.sqlite', kind: 'file' },
|
||||||
|
{ path: 'saves', kind: 'directory' },
|
||||||
|
{ path: 'config', kind: 'file' },
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
{ path: 'config', kind: 'file' },
|
||||||
|
{ path: 'saves', kind: 'directory' },
|
||||||
|
{ path: 'config/bigdb.sqlite', kind: 'file' },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('directory wins when the same exclusion is selected as both kinds', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
canonicalizeBackupExclusions([
|
||||||
|
{ path: 'cache', kind: 'file' },
|
||||||
|
{ path: 'cache', kind: 'directory' },
|
||||||
|
]),
|
||||||
|
[{ path: 'cache', kind: 'directory' }],
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
|
|
||||||
|
export type InstanceBackupEligibility =
|
||||||
|
| 'eligible'
|
||||||
|
| 'not_installed'
|
||||||
|
| 'symlink_instance'
|
||||||
|
| 'direct_linked_instance'
|
||||||
|
| 'external_game_directory'
|
||||||
|
| 'root_missing'
|
||||||
|
| 'root_is_link'
|
||||||
|
|
||||||
|
export interface BackupRepositoryStatus {
|
||||||
|
path: string
|
||||||
|
initialized: boolean
|
||||||
|
available: boolean
|
||||||
|
stored_size: number
|
||||||
|
logical_size: number
|
||||||
|
snapshot_count: number
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupExclusionKind = 'file' | 'directory'
|
||||||
|
|
||||||
|
export interface BackupExclusion {
|
||||||
|
path: string
|
||||||
|
kind: BackupExclusionKind
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBackupExclusionPath(exclusion: BackupExclusion): string {
|
||||||
|
return exclusion.kind === 'directory' ? `${exclusion.path}/` : exclusion.path
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceBackupConfig {
|
||||||
|
instance_id: string
|
||||||
|
enabled: boolean
|
||||||
|
eligibility: InstanceBackupEligibility
|
||||||
|
excluded_paths: BackupExclusion[]
|
||||||
|
snapshot_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSnapshot {
|
||||||
|
id: string
|
||||||
|
instance_id: string
|
||||||
|
instance_name: string
|
||||||
|
created_at: number
|
||||||
|
file_count: number
|
||||||
|
symlink_count: number
|
||||||
|
logical_size: number
|
||||||
|
added_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupDeleteSummary {
|
||||||
|
snapshot_count: number
|
||||||
|
logical_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupRestorePreview {
|
||||||
|
added_files: number
|
||||||
|
modified_files: number
|
||||||
|
deleted_files: number
|
||||||
|
plan_token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupOperationType =
|
||||||
|
'create' | 'restore' | 'restore_preview' | 'delete' | 'repository_move'
|
||||||
|
export type BackupOperationState =
|
||||||
|
| 'queued'
|
||||||
|
| 'scanning'
|
||||||
|
| 'hashing'
|
||||||
|
| 'saving'
|
||||||
|
| 'validating'
|
||||||
|
| 'materializing'
|
||||||
|
| 'applying'
|
||||||
|
| 'completed'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'failed'
|
||||||
|
| 'interrupted'
|
||||||
|
|
||||||
|
export interface BackupOperation {
|
||||||
|
id: string
|
||||||
|
operation_type: BackupOperationType
|
||||||
|
instance_id: string
|
||||||
|
snapshot_id?: string
|
||||||
|
state: BackupOperationState
|
||||||
|
processed_bytes: number
|
||||||
|
total_bytes: number
|
||||||
|
cancellable: boolean
|
||||||
|
cancel_requested: boolean
|
||||||
|
error?: string
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
finished_at?: number
|
||||||
|
restore_preview?: BackupRestorePreview
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupProgressStage =
|
||||||
|
| 'scanning'
|
||||||
|
| 'hashing'
|
||||||
|
| 'saving'
|
||||||
|
| 'validating'
|
||||||
|
| 'copying'
|
||||||
|
| 'restoring'
|
||||||
|
| 'deleting'
|
||||||
|
| 'completed'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'failed'
|
||||||
|
|
||||||
|
export type BackupOperationFinalState = 'completed' | 'cancelled' | 'failed'
|
||||||
|
|
||||||
|
export interface BackupProgressEvent {
|
||||||
|
operationId: string
|
||||||
|
instanceId?: string
|
||||||
|
operationType: BackupOperationType
|
||||||
|
stage: BackupProgressStage
|
||||||
|
processedBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
finalState?: BackupOperationFinalState
|
||||||
|
snapshotId?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeBackupExclusions(values: BackupExclusion[]): BackupExclusion[] {
|
||||||
|
const normalized = values
|
||||||
|
.map((value) => ({ ...value, path: value.path.replaceAll('\\', '/') }))
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.path.split('/').length - right.path.split('/').length ||
|
||||||
|
left.path.localeCompare(right.path),
|
||||||
|
)
|
||||||
|
const result: BackupExclusion[] = []
|
||||||
|
for (const value of normalized) {
|
||||||
|
const duplicate = result.find((existing) => existing.path === value.path)
|
||||||
|
if (duplicate) {
|
||||||
|
if (value.kind === 'directory') duplicate.kind = 'directory'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.some(
|
||||||
|
(parent) => parent.kind === 'directory' && value.path.startsWith(`${parent.path}/`),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.push(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupRepositoryStatus(): Promise<BackupRepositoryStatus> {
|
||||||
|
return invoke('plugin:instance|instance_get_backup_repository_status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveBackupRepository(destination: string): Promise<string> {
|
||||||
|
return invoke('plugin:instance|instance_move_backup_repository', { destination })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupConfig(instanceId: string): Promise<InstanceBackupConfig> {
|
||||||
|
return invoke('plugin:instance|instance_get_backup_config', { instanceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBackupExclusion(
|
||||||
|
instanceId: string,
|
||||||
|
selectedPath: string,
|
||||||
|
kind: BackupExclusionKind,
|
||||||
|
): Promise<BackupExclusion> {
|
||||||
|
return invoke('plugin:instance|instance_normalize_backup_exclusion', {
|
||||||
|
instanceId,
|
||||||
|
selectedPath,
|
||||||
|
kind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableBackups(
|
||||||
|
instanceId: string,
|
||||||
|
excludedPaths: BackupExclusion[],
|
||||||
|
): Promise<InstanceBackupConfig> {
|
||||||
|
return invoke('plugin:instance|instance_enable_backups', { instanceId, excludedPaths })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBackupExclusions(
|
||||||
|
instanceId: string,
|
||||||
|
excludedPaths: BackupExclusion[],
|
||||||
|
): Promise<InstanceBackupConfig> {
|
||||||
|
return invoke('plugin:instance|instance_update_backup_exclusions', {
|
||||||
|
instanceId,
|
||||||
|
excludedPaths,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableBackups(instanceId: string): Promise<void> {
|
||||||
|
return invoke('plugin:instance|instance_disable_backups', { instanceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBackup(instanceId: string): Promise<string> {
|
||||||
|
return invoke('plugin:instance|instance_start_backup', { instanceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelBackup(operationId: string): Promise<boolean> {
|
||||||
|
return invoke('plugin:instance|instance_cancel_backup', { operationId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBackupOperations(
|
||||||
|
instanceId?: string,
|
||||||
|
activeOnly = false,
|
||||||
|
): Promise<BackupOperation[]> {
|
||||||
|
return invoke('plugin:instance|instance_list_backup_operations', {
|
||||||
|
instanceId,
|
||||||
|
activeOnly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBackups(instanceId: string): Promise<BackupSnapshot[]> {
|
||||||
|
return invoke('plugin:instance|instance_list_backups', { instanceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBackup(snapshotId: string): Promise<void> {
|
||||||
|
return invoke('plugin:instance|instance_delete_backup', { snapshotId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBackupRestorePreview(snapshotId: string): Promise<string> {
|
||||||
|
return invoke('plugin:instance|instance_get_backup_restore_preview', { snapshotId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreBackup(snapshotId: string, planToken: string): Promise<string> {
|
||||||
|
return invoke('plugin:instance|instance_restore_backup', { snapshotId, planToken })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupDeleteSummary(instanceId: string): Promise<BackupDeleteSummary> {
|
||||||
|
return invoke('plugin:instance|instance_get_backup_delete_summary', { instanceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listenBackupProgress(
|
||||||
|
handler: (event: BackupProgressEvent) => void,
|
||||||
|
): Promise<UnlistenFn> {
|
||||||
|
return listen<BackupProgressEvent>('instance_backup_progress', (event) => handler(event.payload))
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
|
|
||||||
import { isInstanceLaunching, launchOnce } from './instance-launch-state.ts'
|
import { isInstanceLaunching, launchOnce } from './instance-launch-state.ts'
|
||||||
|
|
||||||
test('launches different instances concurrently and coalesces repeated pending clicks', async () => {
|
test('launches different instances concurrently and coalesces repeated pending clicks', async () => {
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
|
|
||||||
import {
|
import {
|
||||||
setSkinSiteFrame,
|
|
||||||
resetSkinSiteSession,
|
|
||||||
receiveSkinSiteMessage,
|
receiveSkinSiteMessage,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
setSkinSiteFrame,
|
||||||
SKIN_SITE_ORIGIN,
|
SKIN_SITE_ORIGIN,
|
||||||
} from '../composables/skin-site-session.ts'
|
} from '../composables/skin-site-session.ts'
|
||||||
|
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
|
||||||
import {
|
import {
|
||||||
|
type InstancePlayer,
|
||||||
|
onInstancePlayerChanged,
|
||||||
prepareInstancePlayer,
|
prepareInstancePlayer,
|
||||||
registerInstancePlayerPicker,
|
registerInstancePlayerPicker,
|
||||||
saveInstancePlayer,
|
saveInstancePlayer,
|
||||||
onInstancePlayerChanged,
|
|
||||||
type InstancePlayer,
|
|
||||||
} from './instance-player.ts'
|
} from './instance-player.ts'
|
||||||
|
|
||||||
test('instance player selection persists independently from the globally selected account', async () => {
|
test('instance player selection persists independently from the globally selected account', async () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type ContentItem,
|
type ContentItem,
|
||||||
type ContentOwner,
|
type ContentOwner,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
import { isOfflineMode } from '@/composables/useNetworkStatus'
|
import { isOfflineMode } from '@/composables/useNetworkStatus'
|
||||||
import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector'
|
import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector'
|
||||||
@@ -18,8 +18,8 @@ import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategi
|
|||||||
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
|
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
|
||||||
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
|
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
|
||||||
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
|
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
|
||||||
import { prepareInstancePlayer } from '@/helpers/instance-player'
|
|
||||||
import { launchOnce } from '@/helpers/instance-launch-state'
|
import { launchOnce } from '@/helpers/instance-launch-state'
|
||||||
|
import { prepareInstancePlayer } from '@/helpers/instance-player'
|
||||||
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
|
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
|
||||||
import { get_jre, get_memory_status } from '@/helpers/jre.js'
|
import { get_jre, get_memory_status } from '@/helpers/jre.js'
|
||||||
import { get as getSettings } from '@/helpers/settings'
|
import { get as getSettings } from '@/helpers/settings'
|
||||||
@@ -108,6 +108,104 @@ export async function get_many(instanceIds: string[]): Promise<GameInstance[]> {
|
|||||||
return await invoke('plugin:instance|instance_get_many', { instanceIds })
|
return await invoke('plugin:instance|instance_get_many', { instanceIds })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adaptContentItems(items: ContentItem[]): ContentItem[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
const embeddedMetadata = item.embedded_metadata
|
||||||
|
if (!embeddedMetadata?.icon_path) return item
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
embedded_metadata: {
|
||||||
|
...embeddedMetadata,
|
||||||
|
icon_url: convertFileSrc(embeddedMetadata.icon_path),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyncedOption =
|
||||||
|
| 'game_options'
|
||||||
|
| 'command_history'
|
||||||
|
| 'multiplayer_servers'
|
||||||
|
| 'creative_hotbars'
|
||||||
|
| 'screenshots'
|
||||||
|
| 'resource_packs'
|
||||||
|
| 'data_packs'
|
||||||
|
export type GlobalSyncedOptions = Record<SyncedOption, boolean>
|
||||||
|
export type SyncedOptionCapability = {
|
||||||
|
option: SyncedOption
|
||||||
|
supported: boolean
|
||||||
|
disabled_reason: string | null
|
||||||
|
}
|
||||||
|
export type SyncedOptionJoinPreview = { action: string }
|
||||||
|
export type SyncedOptionsOverview = {
|
||||||
|
global_options: GlobalSyncedOptions
|
||||||
|
capabilities: SyncedOptionCapability[]
|
||||||
|
}
|
||||||
|
export async function get_global_synced_options(): Promise<GlobalSyncedOptions> {
|
||||||
|
return await invoke('plugin:instance|instance_get_synced_options')
|
||||||
|
}
|
||||||
|
export async function get_initialized_synced_options(): Promise<GlobalSyncedOptions> {
|
||||||
|
return await invoke('plugin:instance|instance_get_initialized_synced_options')
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Every synced option is currently available on every instance. The parameter
|
||||||
|
* stays so callers keep passing the option they are asking about, which is what
|
||||||
|
* a real implementation will need.
|
||||||
|
*/
|
||||||
|
export function isSyncedOptionAvailable(_option: SyncedOption): boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
export async function set_global_synced_option(
|
||||||
|
option: SyncedOption,
|
||||||
|
enabled: boolean,
|
||||||
|
baseInstanceId?: string | null,
|
||||||
|
): Promise<GlobalSyncedOptions> {
|
||||||
|
return await invoke('plugin:instance|instance_set_synced_option', {
|
||||||
|
option,
|
||||||
|
enabled,
|
||||||
|
baseInstanceId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export async function set_instance_synced_option(
|
||||||
|
instanceId: string,
|
||||||
|
option: SyncedOption,
|
||||||
|
enabled: boolean,
|
||||||
|
resolution?: string | null,
|
||||||
|
): Promise<GameInstance> {
|
||||||
|
return await invoke('plugin:instance|instance_set_instance_synced_option', {
|
||||||
|
instanceId,
|
||||||
|
option,
|
||||||
|
enabled,
|
||||||
|
resolution,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export async function get_synced_options_overview(
|
||||||
|
instanceId: string,
|
||||||
|
): Promise<SyncedOptionsOverview> {
|
||||||
|
return await invoke('plugin:instance|instance_get_synced_options_overview', { instanceId })
|
||||||
|
}
|
||||||
|
export async function get_command_history(): Promise<string> {
|
||||||
|
return await invoke('plugin:instance|instance_get_synced_command_history')
|
||||||
|
}
|
||||||
|
export async function set_command_history(contents: string): Promise<string> {
|
||||||
|
return await invoke('plugin:instance|instance_set_synced_command_history', { contents })
|
||||||
|
}
|
||||||
|
export type SyncedServer = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
accept_textures: boolean | null
|
||||||
|
}
|
||||||
|
export async function list_synced_servers(): Promise<SyncedServer[]> {
|
||||||
|
return await invoke('plugin:instance|instance_list_synced_servers')
|
||||||
|
}
|
||||||
|
export async function update_synced_server(server: SyncedServer): Promise<void> {
|
||||||
|
await invoke('plugin:instance|instance_update_synced_server', { server })
|
||||||
|
}
|
||||||
|
export async function remove_synced_server(id: string): Promise<void> {
|
||||||
|
await invoke('plugin:instance|instance_remove_synced_server', { id })
|
||||||
|
}
|
||||||
|
|
||||||
export async function get_projects(
|
export async function get_projects(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
cacheBehaviour?: CacheBehaviour,
|
cacheBehaviour?: CacheBehaviour,
|
||||||
@@ -1124,3 +1222,90 @@ export async function check_symlink_capability(): Promise<SymlinkCapability> {
|
|||||||
export async function allow_symlink_target(path: string): Promise<void> {
|
export async function allow_symlink_target(path: string): Promise<void> {
|
||||||
return await invoke('allow_symlink_target', { path })
|
return await invoke('allow_symlink_target', { path })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScreenshotKey = { instance_id: string; file_name: string }
|
||||||
|
export type InstanceScreenshot = {
|
||||||
|
id: string
|
||||||
|
instance_id: string
|
||||||
|
instance_name: string
|
||||||
|
file_name: string
|
||||||
|
created_at: string
|
||||||
|
modified_at: number
|
||||||
|
group_id: string | null
|
||||||
|
path: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
export type ScreenshotGroup = { id: string; name: string }
|
||||||
|
export type ScreenshotGroupImport = ScreenshotGroup & { screenshot_ids: string[] }
|
||||||
|
export type ScreenshotGroupMembershipUpdate = { screenshot_id: string; group_id: string | null }
|
||||||
|
|
||||||
|
export async function list_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
|
||||||
|
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
|
||||||
|
}
|
||||||
|
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
|
||||||
|
return await invoke('plugin:instance|instance_list_all_screenshots')
|
||||||
|
}
|
||||||
|
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
|
||||||
|
return await invoke('plugin:instance|instance_list_synced_screenshots')
|
||||||
|
}
|
||||||
|
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
|
||||||
|
return await invoke('plugin:instance|instance_list_screenshot_groups')
|
||||||
|
}
|
||||||
|
export async function create_screenshot_group(
|
||||||
|
name: string,
|
||||||
|
screenshotIds: string[],
|
||||||
|
): Promise<ScreenshotGroup> {
|
||||||
|
return await invoke('plugin:instance|instance_create_screenshot_group', {
|
||||||
|
name,
|
||||||
|
ids: screenshotIds,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export async function rename_screenshot_group(
|
||||||
|
id: string,
|
||||||
|
newName: string,
|
||||||
|
): Promise<ScreenshotGroup> {
|
||||||
|
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
|
||||||
|
}
|
||||||
|
export async function delete_screenshot_group(id: string): Promise<void> {
|
||||||
|
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
|
||||||
|
}
|
||||||
|
export async function set_screenshot_group_memberships(
|
||||||
|
updates: ScreenshotGroupMembershipUpdate[],
|
||||||
|
): Promise<void> {
|
||||||
|
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
|
||||||
|
}
|
||||||
|
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
|
||||||
|
return await invoke('plugin:instance|instance_import_screenshot_groups', { groups })
|
||||||
|
}
|
||||||
|
export async function save_edited_screenshot(
|
||||||
|
key: ScreenshotKey,
|
||||||
|
pngBytes: Uint8Array,
|
||||||
|
mode: 'create_copy' | 'replace_edit',
|
||||||
|
): Promise<InstanceScreenshot> {
|
||||||
|
return await invoke('plugin:instance|instance_save_edited_screenshot', {
|
||||||
|
key,
|
||||||
|
bytes: Array.from(pngBytes),
|
||||||
|
mode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
|
||||||
|
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
|
||||||
|
}
|
||||||
|
export async function export_screenshots(keys: ScreenshotKey[], path: string): Promise<void> {
|
||||||
|
return await invoke('plugin:instance|instance_export_screenshots', { keys, path })
|
||||||
|
}
|
||||||
|
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
|
||||||
|
return await invoke('plugin:instance|instance_open_screenshot', { key })
|
||||||
|
}
|
||||||
|
export async function move_screenshots(
|
||||||
|
keys: ScreenshotKey[],
|
||||||
|
targetInstanceId: string,
|
||||||
|
): Promise<ScreenshotKey[]> {
|
||||||
|
return await invoke('plugin:instance|instance_move_screenshots', {
|
||||||
|
keys,
|
||||||
|
target: targetInstanceId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function getInstanceIconUrl(iconPath?: string | null): string | undefined {
|
||||||
|
return iconPath ? convertFileSrc(iconPath) : undefined
|
||||||
|
}
|
||||||
@@ -64,14 +64,9 @@ export async function get_latest_log_cursor(instanceId, cursor) {
|
|||||||
return await invoke('plugin:logs|logs_get_latest_log_cursor', { instanceId, cursor })
|
return await invoke('plugin:logs|logs_get_latest_log_cursor', { instanceId, cursor })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read Minecraft's logs/latest.log from a cursor.
|
/// Get the authoritative sequenced live-log snapshot from the Rust ring buffer.
|
||||||
export async function get_minecraft_latest_log_cursor(instanceId, cursor) {
|
export async function get_live_log_snapshot(instanceId) {
|
||||||
return await invoke('plugin:logs|logs_get_minecraft_latest_log_cursor', { instanceId, cursor })
|
return await invoke('plugin:logs|logs_get_live_log_snapshot', { instanceId })
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all buffered live log lines for an instance from the Rust ring buffer
|
|
||||||
export async function get_live_log_buffer(instanceId) {
|
|
||||||
return await invoke('plugin:logs|logs_get_live_log_buffer', { instanceId })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the live log buffer for an instance on the Rust side
|
/// Clear the live log buffer for an instance on the Rust side
|
||||||
|
|||||||
@@ -82,20 +82,20 @@ test('notice query uses the target instance value and registered Tauri command',
|
|||||||
assert.match(tauriSource, /tauri::generate_handler!\[[\s\S]*instance_get_post_upgrade_notice/)
|
assert.match(tauriSource, /tauri::generate_handler!\[[\s\S]*instance_get_post_upgrade_notice/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('result uses Modrinth Card and Accordion for collapsed compatibility warnings', () => {
|
test('result collections use Modrinth Card and Accordion for collapsed compatibility warnings', () => {
|
||||||
const source = readFileSync(
|
const source = readFileSync(
|
||||||
new URL('../pages/instance/upgrade/UpgradeResultDetails.vue', import.meta.url),
|
new URL('../pages/instance/upgrade/UpgradeResultCollections.vue', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
assert.match(source, /<Card v-if="warningRows\.length"/)
|
assert.match(source, /<Card v-if="warnings\.length"/)
|
||||||
assert.match(source, /<Accordion[\s\S]*:open-by-default="warningsExpandedByDefault"/)
|
assert.match(source, /<Accordion[\s\S]*:open-by-default="warningsDefaultOpen"/)
|
||||||
assert.match(source, /<TriangleAlertIcon/)
|
assert.match(source, /<TriangleAlertIcon/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('runtime icon references use names exported by the assets package', () => {
|
test('runtime icon references use names exported by the assets package', () => {
|
||||||
const downloadsSource = readFileSync(new URL('../pages/Downloads.vue', import.meta.url), 'utf8')
|
const downloadsSource = readFileSync(new URL('../pages/Downloads.vue', import.meta.url), 'utf8')
|
||||||
const resultSource = readFileSync(
|
const resultSource = readFileSync(
|
||||||
new URL('../pages/instance/upgrade/UpgradeResultDetails.vue', import.meta.url),
|
new URL('../pages/instance/upgrade/UpgradeResultCollections.vue', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
)
|
)
|
||||||
const assetsSource = readFileSync(
|
const assetsSource = readFileSync(
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export type AppSettings = {
|
|||||||
curseforge_source: DownloadSourceMode
|
curseforge_source: DownloadSourceMode
|
||||||
bypass_curseforge_download_restrictions: boolean
|
bypass_curseforge_download_restrictions: boolean
|
||||||
mojang_auth_source: DownloadSourceMode
|
mojang_auth_source: DownloadSourceMode
|
||||||
|
ignore_ssl_errors: boolean
|
||||||
|
|
||||||
theme: ColorTheme
|
theme: ColorTheme
|
||||||
accent_color: AccentColorSetting
|
accent_color: AccentColorSetting
|
||||||
@@ -172,6 +173,9 @@ export type AppSettings = {
|
|||||||
home_layout: HomeLayout
|
home_layout: HomeLayout
|
||||||
minimal_home_instance_id: string | null
|
minimal_home_instance_id: string | null
|
||||||
close_behavior: 'ask' | 'close' | 'lightweight'
|
close_behavior: 'ask' | 'close' | 'lightweight'
|
||||||
|
ui_scale: number
|
||||||
|
ui_font: string | null
|
||||||
|
mono_font: string | null
|
||||||
home_widgets: HomeDashboardConfig | null
|
home_widgets: HomeDashboardConfig | null
|
||||||
terracotta_public_nodes: string[]
|
terracotta_public_nodes: string[]
|
||||||
|
|
||||||
@@ -213,6 +217,9 @@ type LegacyMirrorSettings = {
|
|||||||
|
|
||||||
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
|
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
|
||||||
settings.close_behavior ??= 'ask'
|
settings.close_behavior ??= 'ask'
|
||||||
|
settings.ui_scale ??= 100
|
||||||
|
settings.ui_font ??= null
|
||||||
|
settings.mono_font ??= null
|
||||||
const hasLegacySettings =
|
const hasLegacySettings =
|
||||||
typeof settings.use_minecraft_mirror === 'boolean' &&
|
typeof settings.use_minecraft_mirror === 'boolean' &&
|
||||||
typeof settings.use_modrinth_mirror === 'boolean' &&
|
typeof settings.use_modrinth_mirror === 'boolean' &&
|
||||||
@@ -239,6 +246,7 @@ function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings)
|
|||||||
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_curseforge_mirror)
|
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_curseforge_mirror)
|
||||||
settings.bypass_curseforge_download_restrictions ??= true
|
settings.bypass_curseforge_download_restrictions ??= true
|
||||||
settings.mojang_auth_source ??= 'auto'
|
settings.mojang_auth_source ??= 'auto'
|
||||||
|
settings.ignore_ssl_errors ??= false
|
||||||
settings.terracotta_public_nodes ??= ['wss://center.node.1tmc.top']
|
settings.terracotta_public_nodes ??= ['wss://center.node.1tmc.top']
|
||||||
settings.feature_flags ??= { ...DEFAULT_FEATURE_FLAGS }
|
settings.feature_flags ??= { ...DEFAULT_FEATURE_FLAGS }
|
||||||
for (const [key, value] of Object.entries(DEFAULT_FEATURE_FLAGS)) {
|
for (const [key, value] of Object.entries(DEFAULT_FEATURE_FLAGS)) {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { queryOptions } from '@tanstack/vue-query'
|
||||||
|
|
||||||
|
import { list_game_options_sync_sources } from './game-options'
|
||||||
|
import {
|
||||||
|
get_command_history,
|
||||||
|
get_global_synced_options,
|
||||||
|
get_initialized_synced_options,
|
||||||
|
list_synced_servers,
|
||||||
|
} from './instance'
|
||||||
|
import type { GameInstance } from './types'
|
||||||
|
|
||||||
|
export const syncedOptionsKeys = {
|
||||||
|
global: ['global-synced-options'] as const,
|
||||||
|
initialized: ['initialized-synced-options'] as const,
|
||||||
|
gameSources: ['game-options-sync-sources'] as const,
|
||||||
|
set: ['global-synced-options', 'set'] as const,
|
||||||
|
servers: ['synced-servers', 'list'] as const,
|
||||||
|
commandHistory: ['synced-options', 'command-history'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function globalSyncedOptionsQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: syncedOptionsKeys.global,
|
||||||
|
queryFn: get_global_synced_options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializedSyncedOptionsQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: syncedOptionsKeys.initialized,
|
||||||
|
queryFn: get_initialized_synced_options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gameOptionsSyncSourcesQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: syncedOptionsKeys.gameSources,
|
||||||
|
queryFn: list_game_options_sync_sources,
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncedServersQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: syncedOptionsKeys.servers,
|
||||||
|
queryFn: list_synced_servers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commandHistoryQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: syncedOptionsKeys.commandHistory,
|
||||||
|
queryFn: get_command_history,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canSourceMultiplayerServers(instance: GameInstance): boolean {
|
||||||
|
return !['server_project', 'server_project_modpack', 'modrinth_hosting'].includes(
|
||||||
|
instance.link?.type ?? '',
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { ContentItem } from '@modrinth/ui'
|
||||||
|
import { queryOptions } from '@tanstack/vue-query'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
|
import { adaptContentItems, isSyncedOptionAvailable } from './instance'
|
||||||
|
|
||||||
|
export type SyncedPackType = 'resourcepack' | 'datapack'
|
||||||
|
export type SyncedPackAction = 'enable' | 'disable'
|
||||||
|
|
||||||
|
export type DesyncServerMode = 'keep_in_other_instances' | 'remove_from_other_instances'
|
||||||
|
|
||||||
|
export function isSyncedPackTypeAvailable(projectType: ContentItem['project_type']): boolean {
|
||||||
|
return (
|
||||||
|
['resourcepack', 'datapack'].includes(projectType) &&
|
||||||
|
isSyncedOptionAvailable(projectType === 'resourcepack' ? 'resource_packs' : 'data_packs')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackSyncPreview {
|
||||||
|
pack: ContentItem
|
||||||
|
instances: {
|
||||||
|
instance_id: string
|
||||||
|
name: string
|
||||||
|
game_version: string
|
||||||
|
compatible: boolean
|
||||||
|
participating: boolean
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncedPackKeys = {
|
||||||
|
all: ['synced-packs'] as const,
|
||||||
|
list: (type: SyncedPackType) => ['synced-packs', 'list', type] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncedPackQueryOptions(type: SyncedPackType) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: syncedPackKeys.list(type),
|
||||||
|
queryFn: () => list_synced_packs(type),
|
||||||
|
enabled: isSyncedPackTypeAvailable(type),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list_synced_packs(projectType: SyncedPackType): Promise<ContentItem[]> {
|
||||||
|
const items = await invoke<ContentItem[]>('plugin:instance|instance_list_synced_packs', {
|
||||||
|
projectType,
|
||||||
|
})
|
||||||
|
return adaptContentItems(items).map((item) => ({ ...item, external: !item.project }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get_pack_sync_preview(
|
||||||
|
instanceId: string,
|
||||||
|
projectPath: string,
|
||||||
|
): Promise<PackSyncPreview> {
|
||||||
|
return invoke('plugin:instance|instance_get_pack_sync_preview', { instanceId, projectPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sync_pack(instanceId: string, projectPath: string): Promise<void> {
|
||||||
|
return invoke('plugin:instance|instance_sync_pack', { instanceId, projectPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function desync_pack(
|
||||||
|
instanceId: string,
|
||||||
|
packId: string,
|
||||||
|
mode: DesyncServerMode,
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke('plugin:instance|instance_desync_pack', { instanceId, packId, mode })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upload_synced_pack(
|
||||||
|
path: string,
|
||||||
|
projectType: SyncedPackType,
|
||||||
|
gameVersions: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke('plugin:instance|instance_upload_synced_pack', { path, projectType, gameVersions })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set_synced_pack_enabled(packId: string, enabled: boolean): Promise<void> {
|
||||||
|
return invoke('plugin:instance|instance_set_synced_pack_enabled', { packId, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remove_synced_pack(packId: string): Promise<void> {
|
||||||
|
return invoke('plugin:instance|instance_remove_synced_pack', { packId })
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
|
import type { SystemFontFamily } from './font-family.ts'
|
||||||
|
|
||||||
|
let cached: Promise<SystemFontFamily[]> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installed families are enumerated by the host once per session; installing a
|
||||||
|
* font while the launcher runs shows up after a restart.
|
||||||
|
*/
|
||||||
|
export function getSystemFontFamilies(): Promise<SystemFontFamily[]> {
|
||||||
|
cached ??= invoke<SystemFontFamily[]>('plugin:fonts|fonts_get_system_fonts').catch((error) => {
|
||||||
|
// A failed scan must not poison the session for a later retry.
|
||||||
|
cached = null
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
return cached
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { createTaggedModProgress } from './tagged-mod-progress.ts'
|
import { createTaggedModProgress } from './tagged-mod-progress.ts'
|
||||||
|
|
||||||
const parent = (id = 'parent', fraction: number | null = 0, error = '') => ({
|
const parent = (id = 'parent', fraction: number | null = 0, error = '') => ({
|
||||||
|
|||||||
+11
@@ -23,6 +23,17 @@ export type GameInstance = {
|
|||||||
loader_version?: string
|
loader_version?: string
|
||||||
loader_components: LoaderComponent[]
|
loader_components: LoaderComponent[]
|
||||||
|
|
||||||
|
synced_options: Record<
|
||||||
|
| 'game_options'
|
||||||
|
| 'command_history'
|
||||||
|
| 'multiplayer_servers'
|
||||||
|
| 'creative_hotbars'
|
||||||
|
| 'screenshots'
|
||||||
|
| 'resource_packs'
|
||||||
|
| 'data_packs',
|
||||||
|
boolean
|
||||||
|
>
|
||||||
|
|
||||||
groups: string[]
|
groups: string[]
|
||||||
|
|
||||||
link?: InstanceLink | null
|
link?: InstanceLink | null
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动器界面缩放(百分比)。
|
||||||
|
* 通过 Tauri 原生 webview zoom(Rust 侧 set_zoom)应用,
|
||||||
|
* 缩放值持久化到后端设置数据库(settings.ui_scale)。
|
||||||
|
*/
|
||||||
|
const MIN_UI_SCALE = 50
|
||||||
|
const MAX_UI_SCALE = 200
|
||||||
|
const DEFAULT_UI_SCALE = 100
|
||||||
|
|
||||||
|
/** 把缩放值限制在允许范围内。 */
|
||||||
|
function clampUiScale(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return DEFAULT_UI_SCALE
|
||||||
|
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Math.round(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uiScale = ref(DEFAULT_UI_SCALE)
|
||||||
|
|
||||||
|
export function setUiScale(value: number): void {
|
||||||
|
uiScale.value = clampUiScale(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetUiScale(): void {
|
||||||
|
setUiScale(DEFAULT_UI_SCALE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_UI_SCALE, MAX_UI_SCALE, MIN_UI_SCALE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把缩放值应用到原生 webview。
|
||||||
|
* 同时保留 --ui-scale CSS 变量,供依赖它的样式/锚点使用。
|
||||||
|
*/
|
||||||
|
async function applyUiScale(value: number): Promise<void> {
|
||||||
|
const factor = value / 100
|
||||||
|
document.documentElement.style.setProperty('--ui-scale', String(factor))
|
||||||
|
try {
|
||||||
|
await invoke('plugin:settings|set_ui_scale', { factor })
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('应用界面缩放失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化缩放:应用当前值,并在变化时同步到原生 webview。
|
||||||
|
* 初值由调用方(App 初始化流程)在读取后端设置后通过 setUiScale 覆盖。
|
||||||
|
*/
|
||||||
|
export function installUiScale(): void {
|
||||||
|
void applyUiScale(uiScale.value)
|
||||||
|
watch(uiScale, (value) => {
|
||||||
|
void applyUiScale(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
import { isOfflineMode } from '@/composables/useNetworkStatus'
|
import { isOfflineMode } from '@/composables/useNetworkStatus'
|
||||||
import { prepareInstancePlayer } from '@/helpers/instance-player'
|
|
||||||
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
|
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
|
||||||
import { get_full_path, resolveGcLaunchIntent } from '@/helpers/instance'
|
import { get_full_path, resolveGcLaunchIntent } from '@/helpers/instance'
|
||||||
|
import { prepareInstancePlayer } from '@/helpers/instance-player'
|
||||||
import { openPath } from '@/helpers/utils'
|
import { openPath } from '@/helpers/utils'
|
||||||
|
|
||||||
type BaseWorld = {
|
type BaseWorld = {
|
||||||
@@ -20,7 +20,6 @@ type BaseWorld = {
|
|||||||
export type WorldType = 'singleplayer' | 'server'
|
export type WorldType = 'singleplayer' | 'server'
|
||||||
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
|
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
|
||||||
|
|
||||||
|
|
||||||
export type SingleplayerWorld = BaseWorld & {
|
export type SingleplayerWorld = BaseWorld & {
|
||||||
type: 'singleplayer'
|
type: 'singleplayer'
|
||||||
path: string
|
path: string
|
||||||
@@ -251,7 +250,7 @@ export async function start_join_singleplayer_world(
|
|||||||
world: string,
|
world: string,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
await prepareInstancePlayer(instanceId)
|
await prepareInstancePlayer(instanceId)
|
||||||
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
|
if ((await getInstanceMode(instanceId)) === 'starlight') await prepareHostedSession()
|
||||||
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
||||||
return await invoke('plugin:worlds|start_join_singleplayer_world', {
|
return await invoke('plugin:worlds|start_join_singleplayer_world', {
|
||||||
instanceId,
|
instanceId,
|
||||||
@@ -264,7 +263,7 @@ export async function start_join_singleplayer_world(
|
|||||||
|
|
||||||
export async function start_join_server(instanceId: string, address: string): Promise<unknown> {
|
export async function start_join_server(instanceId: string, address: string): Promise<unknown> {
|
||||||
await prepareInstancePlayer(instanceId)
|
await prepareInstancePlayer(instanceId)
|
||||||
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
|
if ((await getInstanceMode(instanceId)) === 'starlight') await prepareHostedSession()
|
||||||
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
||||||
return await invoke('plugin:worlds|start_join_server', {
|
return await invoke('plugin:worlds|start_join_server', {
|
||||||
instanceId,
|
instanceId,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user