Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9e43e5bab | ||
|
|
db2199cc87 | ||
|
|
f968de1987 | ||
|
|
f10920883d | ||
|
|
bc6b7cac03 | ||
|
|
78e90876a6 | ||
|
|
8bb01924cd | ||
|
|
ec89ec5cec | ||
|
|
227af685d4 | ||
|
|
52b3606062 | ||
|
|
fdfc2d36f5 | ||
|
|
30fbf5e5a6 | ||
|
|
9c92e157a5 | ||
|
|
bc37e5d436 | ||
|
|
0aa901ad48 | ||
|
|
6b0b2814a5 | ||
|
|
115d31e0b4 | ||
|
|
c44fb1a0a5 | ||
|
|
afc03152e4 | ||
|
|
fd341d4b9e | ||
|
|
0af663ac8c | ||
|
|
26a29714b2 | ||
|
|
6501eb6aea | ||
|
|
bf95d715a4 | ||
|
|
1838afce56 | ||
|
|
7f3a1cdf5c | ||
|
|
2d7683b0bb | ||
|
|
6edc44fe26 | ||
|
|
f81a588dc1 | ||
|
|
be3a1eda8c | ||
|
|
0098e9eb07 | ||
|
|
629fe9c26a | ||
|
|
159f9e3f38 | ||
|
|
79d49d7916 | ||
|
|
9c530247ef | ||
|
|
daaaebc5a8 | ||
|
|
598fb5c679 | ||
|
|
2542f6dcb6 | ||
|
|
1a4fd54b68 | ||
|
|
a325298eb6 | ||
|
|
aaf394f790 | ||
|
|
3c8ac04269 | ||
|
|
71b8d111fb | ||
|
|
febbd84ae7 | ||
|
|
667c1a0900 | ||
|
|
ac38c98203 | ||
|
|
ef1435bd31 | ||
|
|
87de306245 | ||
|
|
fbcbb803ac | ||
|
|
7b3a5212e6 | ||
|
|
8d73689356 | ||
|
|
4a3b4268b9 | ||
|
|
64f2f85607 | ||
|
|
ede28e0062 | ||
|
|
b0178c046f | ||
|
|
57ff974d79 | ||
|
|
ac9373ddae | ||
|
|
47c4a3973d | ||
|
|
a85e88339f | ||
|
|
d4eb492772 | ||
|
|
e1aa1b47ec | ||
|
|
023447fe85 | ||
|
|
1baf1a041e | ||
|
|
6a0d9b719c | ||
|
|
ba43b9d5bc | ||
|
|
1c671943b7 | ||
|
|
eef7d02f45 | ||
|
|
947a739672 | ||
|
|
799d542227 | ||
|
|
77258eee9d | ||
|
|
a4615c7b73 | ||
|
|
146b88afce | ||
|
|
9bdc965cd5 | ||
|
|
d6d4fcf8a0 | ||
|
|
8cc4782497 | ||
|
|
77b381b8d0 | ||
|
|
4124f26095 | ||
|
|
041d288b25 | ||
|
|
86306b8ddd | ||
|
|
8599a014ad | ||
|
|
bf175160b0 | ||
|
|
d17a66e44f | ||
|
|
3198eac35a | ||
|
|
d83a65a052 | ||
|
|
00f0f111d8 | ||
|
|
0edf96fc2c | ||
|
|
871b0ee001 | ||
|
|
adebde714d | ||
|
|
0195cbf5ac | ||
|
|
13a56f4200 | ||
|
|
d3a510eee6 | ||
|
|
8b94e7dbbe | ||
|
|
ccb96a8cf1 | ||
|
|
0f35a5f707 | ||
|
|
6fb73490d3 | ||
|
|
2dcf26c9d9 | ||
|
|
9ccacba3fd | ||
|
|
54499dd9da | ||
|
|
fce6c60759 | ||
|
|
dcbad9447d | ||
|
|
4abf643852 | ||
|
|
a8d8e1674b | ||
|
|
60f3fd8816 | ||
|
|
c71c220446 | ||
|
|
a95153d0b1 | ||
|
|
853147b04d | ||
|
|
8d61826c5f | ||
|
|
2e92887eb2 | ||
|
|
47bdb1e79c | ||
|
|
d6fdb4f453 | ||
|
|
4ba1ed279f | ||
|
|
afdd091971 | ||
|
|
99e0aa9d14 | ||
|
|
22bc631293 | ||
|
|
3d3682023e | ||
|
|
f253d5529b | ||
|
|
253141b6ff | ||
|
|
946a477397 | ||
|
|
eabac1f11e | ||
|
|
c9a2eeb53d | ||
|
|
b776b99f26 | ||
|
|
daeedc4951 | ||
|
|
79addd4aba | ||
|
|
cadb7bbdcd | ||
|
|
7f3ab9d79b | ||
|
|
037bff2db3 | ||
|
|
1406f7ded3 | ||
|
|
c54acd2e36 | ||
|
|
1b6534ad8c | ||
|
|
2477fc62ca | ||
|
|
3f6678ac2f | ||
|
|
e36329ff0e | ||
|
|
c718d75774 | ||
|
|
544611738d | ||
|
|
98396c6bcd | ||
|
|
403a883e0b | ||
|
|
53c749aaea | ||
|
|
addc31c7c1 | ||
|
|
25978bcd8f | ||
|
|
117eaad9ff | ||
|
|
91e2c369f5 | ||
|
|
ae2992d7f8 | ||
|
|
20d5d2baab | ||
|
|
748d24b264 | ||
|
|
bb0b094002 | ||
|
|
386db4bdb5 | ||
|
|
f98dbf1592 | ||
|
|
48aff7550a | ||
|
|
c000e6a535 | ||
|
|
2ed2791316 | ||
|
|
d422f3aeaf | ||
|
|
a21fe3750b | ||
|
|
8a447f33f6 | ||
|
|
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 |
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
|
||||
# Modrinth services remain disabled by Axolotl capabilities at runtime.
|
||||
[env]
|
||||
@@ -12,5 +5,12 @@ MODRINTH_API_BASE_URL = "https://api.modrinth.com"
|
||||
MODRINTH_API_URL = "https://api.modrinth.com/v2/"
|
||||
MODRINTH_API_URL_V3 = "https://api.modrinth.com/v3/"
|
||||
MODRINTH_LAUNCHER_META_URL = "https://launcher-meta.modrinth.com/"
|
||||
MODRINTH_URL = "https://modrinth.com/"
|
||||
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"
|
||||
@@ -58,24 +58,34 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
- name: Restore Rust dependencies
|
||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
shared-key: desktop-${{ runner.os }}-${{ runner.arch }}
|
||||
- name: Set up Rust compiler cache
|
||||
if: runner.os == 'Windows'
|
||||
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
|
||||
id: sccache-setup
|
||||
continue-on-error: true
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.18.0
|
||||
- name: Check Rust compiler cache availability
|
||||
id: sccache
|
||||
if: runner.os == 'Windows'
|
||||
if: steps.sccache-setup.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
run: ${SCCACHE_PATH} rustc -vV
|
||||
run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV'
|
||||
- name: Enable Rust compiler cache
|
||||
if: runner.os == 'Windows' && steps.sccache.outcome == 'success'
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV"
|
||||
echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV"
|
||||
echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV"
|
||||
- name: Report unavailable Rust compiler cache
|
||||
if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure'
|
||||
shell: bash
|
||||
run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.'
|
||||
- if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- run: pnpm install --frozen-lockfile
|
||||
@@ -112,9 +122,10 @@ jobs:
|
||||
}
|
||||
Write-Output "Built native NSIS installer: $($installers[0].Name)"
|
||||
- name: Show Rust compiler cache statistics
|
||||
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always()
|
||||
if: always() && steps.sccache.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: ${SCCACHE_PATH} --show-stats
|
||||
run: '"${SCCACHE_PATH}" --show-stats'
|
||||
|
||||
website:
|
||||
needs: guardrails
|
||||
|
||||
@@ -51,6 +51,7 @@ jobs:
|
||||
node scripts/axolotl/check-migrations.mjs --release
|
||||
node scripts/axolotl/brand-guard.mjs
|
||||
node scripts/axolotl/i18n-check.mjs
|
||||
node --test scripts/axolotl/publish-skin-site.test.mjs
|
||||
cargo fmt --all --check
|
||||
|
||||
- name: Check updater signing configuration
|
||||
@@ -130,26 +131,38 @@ jobs:
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
|
||||
|
||||
- name: Restore Rust dependencies
|
||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
shared-key: desktop-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Set up Rust compiler cache
|
||||
if: runner.os == 'Windows'
|
||||
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
|
||||
id: sccache-setup
|
||||
continue-on-error: true
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.18.0
|
||||
|
||||
- name: Check Rust compiler cache availability
|
||||
id: sccache
|
||||
if: runner.os == 'Windows'
|
||||
if: steps.sccache-setup.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
run: ${SCCACHE_PATH} rustc -vV
|
||||
run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV'
|
||||
|
||||
- name: Enable Rust compiler cache
|
||||
if: runner.os == 'Windows' && steps.sccache.outcome == 'success'
|
||||
if: steps.sccache.outcome == 'success'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV"
|
||||
echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV"
|
||||
echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV"
|
||||
|
||||
- name: Report unavailable Rust compiler cache
|
||||
if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure'
|
||||
shell: bash
|
||||
run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.'
|
||||
|
||||
- name: Add macOS universal targets
|
||||
if: runner.os == 'macOS'
|
||||
@@ -192,11 +205,6 @@ jobs:
|
||||
updaterJsonPreferNsis: true
|
||||
retryAttempts: 2
|
||||
|
||||
- name: Show Rust compiler cache statistics
|
||||
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always()
|
||||
shell: bash
|
||||
run: ${SCCACHE_PATH} --show-stats
|
||||
|
||||
- name: Publish Windows distribution variants
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
@@ -282,10 +290,19 @@ jobs:
|
||||
throw "Binary not found at $appExe"
|
||||
}
|
||||
|
||||
- name: Show Rust compiler cache statistics
|
||||
if: always() && steps.sccache.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: '"${SCCACHE_PATH}" --show-stats'
|
||||
|
||||
verify-and-publish:
|
||||
name: Verify and publish
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: skin-site-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
|
||||
@@ -298,6 +315,11 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Download updater signatures
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -351,6 +373,39 @@ jobs:
|
||||
release-check/update-server-catalog.json
|
||||
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: Publish to skin site
|
||||
env:
|
||||
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
|
||||
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
bundle="release-check/Starlight_Launcher_${version}_skin-site-update.zip"
|
||||
node scripts/axolotl/publish-skin-site.mjs "${bundle}" "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Notify Update Server to publish release assets
|
||||
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
|
||||
env:
|
||||
|
||||
@@ -12,13 +12,18 @@ on:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Post changelog comment
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Post or update changelog comment
|
||||
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
|
||||
|
||||
@@ -22,8 +22,5 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
|
||||
with:
|
||||
tool: tombi@1.1.2
|
||||
- run: tombi lint
|
||||
- run: tombi fmt --check
|
||||
- run: npx --yes tombi@1.6.1 lint
|
||||
- run: npx --yes tombi@1.6.1 fmt --check
|
||||
@@ -22,5 +22,5 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
|
||||
- run: cargo binstall --no-confirm cargo-shear
|
||||
- run: cargo binstall --no-confirm cargo-shear@1.13.4
|
||||
- run: cargo shear
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Diagnose skin site CI authentication
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
diagnose:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Inspect token format without revealing credentials
|
||||
env:
|
||||
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
|
||||
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
const token = process.env.SKIN_SITE_TOKEN || ''
|
||||
const checks = {
|
||||
configured: token.length > 0,
|
||||
containsWhitespace: /\s/.test(token),
|
||||
containsComma: token.includes(','),
|
||||
containsQuotes: /["']/.test(token),
|
||||
containsBearerPrefix: /^Bearer\s/i.test(token),
|
||||
bearerTokenFormatValid: /^[A-Za-z0-9._~+/-]+=*$/.test(token),
|
||||
}
|
||||
console.log(JSON.stringify(checks))
|
||||
if (!checks.bearerTokenFormatValid) {
|
||||
console.error('::error::CI secret is not a valid Bearer token. No credential value has been logged.')
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
const base = process.env.SKIN_SITE_URL.replace(/\/$/, '')
|
||||
if (new URL(base).protocol !== 'https:') throw new Error('HTTPS is required')
|
||||
try {
|
||||
const response = await fetch(base + '/starlight/launcher/admin', {
|
||||
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
|
||||
redirect: 'error', signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
console.log('Read-only CI authentication HTTP status: ' + response.status)
|
||||
await response.body?.cancel()
|
||||
if (!response.ok) process.exitCode = 1
|
||||
} catch {
|
||||
console.error('::error::Read-only CI authentication request failed; credentials have not been logged.')
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
NODE
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Sync release to skin site
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing GitHub release tag to sync (for example v1.0.2-beta10)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: skin-site-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Upload and activate existing release
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Check out publisher
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Download complete update package
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo '::error::Expected a semantic version tag beginning with v'
|
||||
exit 1
|
||||
fi
|
||||
version="${RELEASE_TAG#v}"
|
||||
gh release download "${RELEASE_TAG}" \
|
||||
--pattern "Starlight_Launcher_${version}_skin-site-update.zip" \
|
||||
--dir release-check
|
||||
|
||||
- name: Publish to skin site
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
|
||||
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${RELEASE_TAG#v}"
|
||||
node scripts/axolotl/publish-skin-site.mjs \
|
||||
"release-check/Starlight_Launcher_${version}_skin-site-update.zip" "${RELEASE_TAG}"
|
||||
@@ -38,6 +38,7 @@ node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
replay_pid*.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@
|
||||
|
||||
如果你遭遇或目睹了违反本准则的行为,请尽快联系项目维护者:
|
||||
|
||||
- 对于需要保密的事件,请在 [Starlight Issues](https://git.starlight.cool/AxTps/Starlight_Lancher/issues) 中仅留下“需要私下联系维护者”的简短说明,不要公开事件细节、个人信息、账号令牌或完整日志;维护者会通过可用的私下渠道与你联系。
|
||||
- 对于需要保密的事件,请在 [Starlight Issues](https://git.starlight.cool/aptxyyds/Starlight_Launcher/issues) 中仅留下“需要私下联系维护者”的简短说明,不要公开事件细节、个人信息、账号令牌或完整日志;维护者会通过可用的私下渠道与你联系。
|
||||
- 对于一般性的社区问题,可以在相关 Issue 或官方 QQ 群(737601250)中提出。请勿在公开频道发布可能使当事人被识别的敏感信息。
|
||||
|
||||
维护者会及时、公平地受理和调查报告,并尽可能保护报告人的隐私。请仅提供与事件相关且你有权分享的信息;恶意或明显虚假的报告也可能构成对社区的骚扰。
|
||||
@@ -115,7 +115,7 @@ Maintainers may remove, edit, or reject comments, commits, issues, pull requests
|
||||
|
||||
If you experience or witness a violation of this Code of Conduct, please contact the project maintainers as soon as possible:
|
||||
|
||||
- For confidential matters, please leave only a brief note saying "Need to contact maintainers privately" in the [Starlight Issues](https://git.starlight.cool/AxTps/Starlight_Lancher/issues), without publicly disclosing event details, personal information, account tokens, or full logs; the maintainers will contact you through available private channels.
|
||||
- For confidential matters, please leave only a brief note saying "Need to contact maintainers privately" in the [Starlight Issues](https://git.starlight.cool/aptxyyds/Starlight_Launcher/issues), without publicly disclosing event details, personal information, account tokens, or full logs; the maintainers will contact you through available private channels.
|
||||
- For general community issues, you can raise them in the relevant Issue or the official QQ group (737601250). Do not post sensitive information in public channels that could identify the parties involved.
|
||||
|
||||
Maintainers will promptly and fairly receive and investigate reports, and protect the privacy of reporters to the best of their ability. Please only provide information that is relevant to the incident and that you are authorized to share; malicious or clearly false reports may also constitute harassment of the community.
|
||||
|
||||
Generated
+50
-2
@@ -1889,6 +1889,28 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -3477,6 +3499,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
@@ -5102,6 +5133,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
@@ -5758,6 +5795,15 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
@@ -6795,7 +6841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus"
|
||||
version = "1.0.0-beta12"
|
||||
version = "1.0.2-beta15"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"async-minecraft-ping",
|
||||
@@ -6899,7 +6945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_gui"
|
||||
version = "1.0.0-beta12"
|
||||
version = "1.0.2-beta15"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"async_zip",
|
||||
@@ -6912,6 +6958,7 @@ dependencies = [
|
||||
"either",
|
||||
"enumset",
|
||||
"flate2",
|
||||
"fontdb",
|
||||
"futures",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
@@ -6926,6 +6973,7 @@ dependencies = [
|
||||
"path-util",
|
||||
"quartz_nbt",
|
||||
"regex",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
|
||||
+8
-24
@@ -20,9 +20,7 @@ ariadne = { path = "packages/ariadne" }
|
||||
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.89"
|
||||
async-tungstenite = { version = "0.31.0", default-features = false, features = [
|
||||
"futures-03-sink"
|
||||
] }
|
||||
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
|
||||
async-walkdir = "2.1.0"
|
||||
async_zip = "0.0.18"
|
||||
base64 = "0.22.1"
|
||||
@@ -32,9 +30,7 @@ bytes = "1.10.1"
|
||||
cc = "1.2.56"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.42"
|
||||
cidre = { version = "0.15.0", default-features = false, features = [
|
||||
"macos_15_0"
|
||||
] }
|
||||
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
|
||||
crc32fast = "1.5.0"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
dashmap = "6.1.0"
|
||||
@@ -48,6 +44,7 @@ encoding_rs = "0.8.35"
|
||||
enumset = "1.1.10"
|
||||
eyre = "0.6.12"
|
||||
flate2 = "1.1.4"
|
||||
fontdb = "0.24.0"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-lite = "2.6.1"
|
||||
@@ -58,11 +55,7 @@ hmac = "0.12.1"
|
||||
http = "1.3.1"
|
||||
hyper = "1.7.0"
|
||||
hyper-util = "0.1.17"
|
||||
image = { version = "0.25.5", default-features = false, features = [
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp"
|
||||
] }
|
||||
image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
indicatif = "0.18.0"
|
||||
itertools = "0.14.0"
|
||||
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"
|
||||
reqwest = { version = "0.12.24", default-features = false }
|
||||
rgb = "0.8.52"
|
||||
rustls = { version = "0.23.42", default-features = false, features = [
|
||||
"ring",
|
||||
"std",
|
||||
"tls12"
|
||||
] }
|
||||
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rustls-native-certs = "0.8.4"
|
||||
rustls-pki-types = "1.15.0"
|
||||
semver = "1.0.28"
|
||||
serde = "1.0.228"
|
||||
serde_bytes = "0.11.19"
|
||||
serde_cbor = "0.11.2"
|
||||
@@ -127,10 +117,7 @@ tempfile = "3.23.0"
|
||||
theseus = { path = "packages/app-lib" }
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.47.1"
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
"ring",
|
||||
"tls12"
|
||||
] }
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
|
||||
tokio-util = "0.7.16"
|
||||
toml = "0.8.19"
|
||||
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"
|
||||
xz2 = { version = "0.1.7", features = ["static"] }
|
||||
zbus = "5.11.0"
|
||||
zhconv = { version = "0.4.1", default-features = false, features = [
|
||||
"compress",
|
||||
"opencc"
|
||||
] }
|
||||
zhconv = { version = "0.4.1", default-features = false, features = ["compress", "opencc"] }
|
||||
zip = { version = "6.0.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
<p><strong>次世代 Minecraft 桌面客户端,全能、美观、全平台覆盖。</strong></p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/ApTx2012/Starlight_Lancher/actions">
|
||||
<a href="https://github.com/StarLight-Server/Starlight_Launcher/actions">
|
||||
<img src="https://img.shields.io/badge/Repo-GitHub-black?style=for-the-badge&logo=github" alt="Repository" />
|
||||
</a>
|
||||
<a href="https://github.com/ApTx2012/Starlight_Lancher/releases">
|
||||
<a href="https://github.com/StarLight-Server/Starlight_Launcher/releases">
|
||||
<img src="https://img.shields.io/badge/Releases-Download-green?style=for-the-badge&logo=github" alt="Releases" />
|
||||
</a>
|
||||
<a href="https://github.com/ApTx2012/Starlight_Lancher/stargazers">
|
||||
<a href="https://github.com/StarLight-Server/Starlight_Launcher/stargazers">
|
||||
<img src="https://img.shields.io/badge/Stars-★-ffb800?style=for-the-badge&logo=github" alt="Stars" />
|
||||
</a>
|
||||
<a href="COPYING.md">
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<p>
|
||||
<a href="https://skin.starlight.cool/">官方网站</a> |
|
||||
<a href="https://github.com/ApTx2012/Starlight_Lancher/releases/latest">下载最新版</a> |
|
||||
<a href="https://github.com/StarLight-Server/Starlight_Launcher/releases/latest">下载最新版</a> |
|
||||
<a href="CONTRIBUTING.md">参与贡献</a>
|
||||
| <a href="CODE_OF_CONDUCT.md">行为准则</a>
|
||||
</p>
|
||||
@@ -57,7 +57,7 @@ _(注:本项目是调用 Modrinth 公开 API 的独立客户端,与 Rinth, I
|
||||
|
||||
## 下载与安装
|
||||
|
||||
请前往 [Releases](https://github.com/ApTx2012/Starlight_Lancher/releases/latest) 下载适合你操作系统的最新安装包。
|
||||
请前往 [Releases](https://github.com/StarLight-Server/Starlight_Launcher/releases/latest) 下载适合你操作系统的最新安装包。
|
||||
已安装的用户每次均可通过内置的 Tauri 签名校验机制,自动在后台完成更新,无需手动下载安装更新。
|
||||
|
||||
| 系统平台 | 推荐下载文件 |
|
||||
|
||||
@@ -14,6 +14,8 @@ extend-exclude = [
|
||||
"packages/app-lib/src/api/content_search/WikiEntries.txt",
|
||||
# Bundled Monaco editor runtime contains upstream generated assets.
|
||||
"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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta12",
|
||||
"version": "1.0.2-beta15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,6 +14,9 @@
|
||||
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
|
||||
},
|
||||
"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",
|
||||
"@modrinth/api-client": "workspace:^",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
@@ -47,7 +50,8 @@
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^10.0.0",
|
||||
"vue-router": "^4.6.0",
|
||||
"vuedraggable": "^4.1.0"
|
||||
"vuedraggable": "^4.1.0",
|
||||
"xxhash-wasm": "1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.1.1",
|
||||
|
||||
+147
-42
@@ -8,6 +8,7 @@ import {
|
||||
FlaskConicalIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
ImagesIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
SettingsIcon,
|
||||
SpinnerIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
WorldIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -63,13 +65,14 @@ import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { type as getOsType } from '@tauri-apps/plugin-os'
|
||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||
import { hideAllPoppers } from 'floating-vue'
|
||||
import { computed, 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 InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
|
||||
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
|
||||
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
|
||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import AnniversaryCelebration from '@/components/ui/AnniversaryCelebration.vue'
|
||||
import AppActionBar from '@/components/ui/AppActionBar.vue'
|
||||
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
@@ -91,11 +94,14 @@ import NavButton from '@/components/ui/NavButton.vue'
|
||||
import NavRail from '@/components/ui/NavRail.vue'
|
||||
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
|
||||
import RemoteAnnouncements from '@/components/ui/RemoteAnnouncements.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { initSkinSiteSession } from '@/composables/skin-site-session'
|
||||
import { useDropImport } from '@/composables/useDropImport'
|
||||
import { setupKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
|
||||
@@ -127,6 +133,8 @@ import {
|
||||
} from '@/helpers/settings.ts'
|
||||
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
|
||||
import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
|
||||
import { shouldDelayAppUpdate } from '@/helpers/app-update-policy'
|
||||
import {
|
||||
areUpdatesEnabled,
|
||||
backupAppDbForUpdate,
|
||||
@@ -178,6 +186,8 @@ const onSchematicWorkshopPage = computed(() => route.path === '/lab/schematic-pr
|
||||
const isSchematicFile = (path: string) => /\.(litematic|schematic|schem)$/i.test(path)
|
||||
const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
|
||||
setupKeyboardShortcuts()
|
||||
|
||||
const discoverContentPath = computed(() => {
|
||||
const projectType = route.params.projectType
|
||||
if (
|
||||
@@ -311,6 +321,22 @@ const showOnboarding = ref(false)
|
||||
const onboardingMode = ref('main')
|
||||
const onboardingSettings = ref(null)
|
||||
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 os = ref('')
|
||||
@@ -498,6 +524,7 @@ onMounted(async () => {
|
||||
checkUpdates()
|
||||
void warnIfRunningElevated()
|
||||
startDirectLinkSync()
|
||||
maybeShowStartupAnnouncement()
|
||||
})
|
||||
|
||||
let directLinkSync: (() => Promise<void>) | undefined
|
||||
@@ -746,6 +773,10 @@ const messages = defineMessages({
|
||||
id: 'app.navigation.multiplayer',
|
||||
defaultMessage: 'Multiplayer',
|
||||
},
|
||||
screenshots: {
|
||||
id: 'app.navigation.screenshots',
|
||||
defaultMessage: 'Screenshots',
|
||||
},
|
||||
downloads: {
|
||||
id: 'app.navigation.downloads',
|
||||
defaultMessage: 'Downloads',
|
||||
@@ -1071,6 +1102,7 @@ async function setupApp() {
|
||||
close_behavior,
|
||||
developer_mode,
|
||||
feature_flags,
|
||||
ui_scale,
|
||||
} = initialSettings
|
||||
|
||||
// Initialize locale from saved settings
|
||||
@@ -1101,6 +1133,7 @@ async function setupApp() {
|
||||
nativeDecorations.value = native_decorations
|
||||
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
|
||||
|
||||
setUiScale(ui_scale)
|
||||
themeStore.setThemeState(theme)
|
||||
await initializeSystemAccentColor()
|
||||
themeStore.setAccentColor(accent_color)
|
||||
@@ -1127,6 +1160,7 @@ async function setupApp() {
|
||||
stateInitialized.value = true
|
||||
showOnboarding.value = !onboarded
|
||||
void reconcileMojangAuthSourceAtStartup().catch(handleError)
|
||||
void initSkinSiteSession().catch(handleError)
|
||||
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
|
||||
@@ -1233,7 +1267,7 @@ async function replayOnboarding(mode) {
|
||||
|
||||
async function finishOnboarding() {
|
||||
const wasReplay = onboardingReplay.value
|
||||
const settings = onboardingSettings.value ?? (await getSettings())
|
||||
const settings = await getSettings()
|
||||
if (!onboardingReplay.value) {
|
||||
if (onboardingMode.value === 'instance') {
|
||||
settings.onboarding_instance_tour_completed = true
|
||||
@@ -1269,6 +1303,9 @@ provide(
|
||||
)
|
||||
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
|
||||
provide('showLauncherPopup', (_request: unknown) => {})
|
||||
provide('showStartupAnnouncement', () => startupAnnouncementModal.value?.show())
|
||||
|
||||
installUiScale()
|
||||
|
||||
const stateFailed = ref(false)
|
||||
stateInitialization
|
||||
@@ -1538,6 +1575,33 @@ const updateToPlayModal = 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 ──────────────────────────────────────────────────
|
||||
const dropImport = useDropImport({
|
||||
notificationManager,
|
||||
@@ -1935,34 +1999,55 @@ function showDelayedUpdatePopup() {
|
||||
}
|
||||
|
||||
let lastUpdateChannel = 'release'
|
||||
let updateCheckRevision = 0
|
||||
|
||||
async function clearAvailableUpdate(isCurrent) {
|
||||
const unlisten = unlistenUpdateDownload
|
||||
unlistenUpdateDownload = null
|
||||
await unlisten?.()
|
||||
if (!isCurrent()) return
|
||||
availableUpdate.value = null
|
||||
updateSize.value = null
|
||||
appUpdateDownload.progress.value = 0
|
||||
finishedDownloading.value = false
|
||||
downloading.value = false
|
||||
clearDelayedUpdatePopup()
|
||||
}
|
||||
|
||||
async function performUpdateCheck() {
|
||||
const revision = ++updateCheckRevision
|
||||
const isLatestCheck = () => revision === updateCheckRevision
|
||||
const currentResult = () => (availableUpdate.value ? 'available' : 'up-to-date')
|
||||
const channel = await getUpdateChannel()
|
||||
const preferences = await getUpdatePreferences()
|
||||
if (!isLatestCheck()) return currentResult()
|
||||
updatesPaused.value = preferences.updatesPaused
|
||||
if (updatesPaused.value) return 'paused'
|
||||
if (channel !== lastUpdateChannel) {
|
||||
availableUpdate.value = null
|
||||
updateSize.value = null
|
||||
appUpdateDownload.progress.value = 0
|
||||
finishedDownloading.value = false
|
||||
downloading.value = false
|
||||
await clearAvailableUpdate(isLatestCheck)
|
||||
if (!isLatestCheck()) return currentResult()
|
||||
lastUpdateChannel = channel
|
||||
}
|
||||
|
||||
const update = await checkAppUpdate(channel)
|
||||
let update
|
||||
try {
|
||||
update = await checkAppUpdate(channel)
|
||||
} catch (error) {
|
||||
if (!isLatestCheck()) return currentResult()
|
||||
throw error
|
||||
}
|
||||
if (!isLatestCheck()) return currentResult()
|
||||
if (!update || update.version !== availableUpdate.value?.version) {
|
||||
await clearAvailableUpdate(isLatestCheck)
|
||||
}
|
||||
if (!isLatestCheck()) return currentResult()
|
||||
if (!update) {
|
||||
console.log('No update available')
|
||||
return 'up-to-date'
|
||||
}
|
||||
|
||||
const publishedAt = Date.parse(update.publishedAt ?? '')
|
||||
if (
|
||||
channel === 'release' &&
|
||||
!preferences.immediateUpdateFetch &&
|
||||
!update.forceUpdate &&
|
||||
(!Number.isFinite(publishedAt) || Date.now() < publishedAt + 24 * 60 * 60 * 1000)
|
||||
) {
|
||||
if (shouldDelayAppUpdate(update, channel, preferences.immediateUpdateFetch)) {
|
||||
console.warn(
|
||||
Number.isFinite(publishedAt)
|
||||
? `Update ${update.version} is waiting for the release delay.`
|
||||
@@ -1979,6 +2064,9 @@ async function performUpdateCheck() {
|
||||
return 'available'
|
||||
}
|
||||
|
||||
const meteredNetwork = await isNetworkMetered()
|
||||
if (!isLatestCheck()) return currentResult()
|
||||
|
||||
appUpdateDownload.progress.value = 0
|
||||
finishedDownloading.value = false
|
||||
downloading.value = false
|
||||
@@ -1987,7 +2075,7 @@ async function performUpdateCheck() {
|
||||
|
||||
console.log(`Update ${update.version} is available.`)
|
||||
|
||||
metered.value = await isNetworkMetered()
|
||||
metered.value = meteredNetwork
|
||||
|
||||
if (!metered.value) {
|
||||
console.log('Starting download of update')
|
||||
@@ -1999,7 +2087,9 @@ async function performUpdateCheck() {
|
||||
}
|
||||
|
||||
getUpdateSize(update.rid)
|
||||
.then((size) => (updateSize.value = size))
|
||||
.then((size) => {
|
||||
if (availableUpdate.value?.rid === update.rid) updateSize.value = size
|
||||
})
|
||||
.catch((error) => console.warn('Failed to fetch update size', error))
|
||||
return 'available'
|
||||
}
|
||||
@@ -2029,6 +2119,7 @@ async function downloadUpdate(versionToDownload) {
|
||||
handleError(`Failed to download update: no version available`)
|
||||
return
|
||||
}
|
||||
if (availableUpdate.value?.rid !== versionToDownload.rid) return
|
||||
|
||||
if (downloading.value || appUpdateDownload.progress.value !== 0) {
|
||||
console.error(`Update ${versionToDownload.version} already downloading`)
|
||||
@@ -2037,35 +2128,27 @@ async function downloadUpdate(versionToDownload) {
|
||||
|
||||
console.log(`Downloading update ${versionToDownload.version} from Update Server`)
|
||||
downloading.value = true
|
||||
const isCurrent = () => availableUpdate.value?.rid === versionToDownload.rid
|
||||
let unlisten
|
||||
|
||||
try {
|
||||
enqueueUpdateForInstallation(versionToDownload.rid)
|
||||
.then(() => {
|
||||
downloading.value = false
|
||||
finishedDownloading.value = true
|
||||
unlistenUpdateDownload?.().then(() => {
|
||||
unlistenUpdateDownload = null
|
||||
})
|
||||
console.log('Finished downloading!')
|
||||
markAppUpdateActionable(versionToDownload.version, 'downloaded')
|
||||
scheduleDelayedUpdatePopup()
|
||||
})
|
||||
.catch((error) => {
|
||||
downloading.value = false
|
||||
appUpdateDownload.progress.value = 0
|
||||
unlistenUpdateDownload?.().then(() => {
|
||||
unlistenUpdateDownload = null
|
||||
})
|
||||
handleError(error)
|
||||
})
|
||||
unlistenUpdateDownload = await subscribeToDownloadProgress(
|
||||
appUpdateDownload,
|
||||
versionToDownload.version,
|
||||
)
|
||||
unlisten = await subscribeToDownloadProgress(appUpdateDownload, versionToDownload.version)
|
||||
if (!isCurrent()) return
|
||||
unlistenUpdateDownload = unlisten
|
||||
await enqueueUpdateForInstallation(versionToDownload.rid)
|
||||
if (!isCurrent()) return
|
||||
downloading.value = false
|
||||
finishedDownloading.value = true
|
||||
markAppUpdateActionable(versionToDownload.version, 'downloaded')
|
||||
scheduleDelayedUpdatePopup()
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return
|
||||
downloading.value = false
|
||||
appUpdateDownload.progress.value = 0
|
||||
handleError(error)
|
||||
} finally {
|
||||
await unlisten?.()
|
||||
if (unlistenUpdateDownload === unlisten) unlistenUpdateDownload = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2164,9 +2247,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AnniversaryCelebration />
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<SkinSiteSessionFrame v-if="stateInitialized" />
|
||||
<InstancePlayerModal v-if="stateInitialized" />
|
||||
<StartupAnnouncementModal ref="startupAnnouncementModal" />
|
||||
<RemoteAnnouncements :ready="stateInitialized && !showOnboarding && !stateFailed" />
|
||||
<div id="teleports"></div>
|
||||
<div
|
||||
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
||||
@@ -2262,6 +2347,23 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<LibraryIcon />
|
||||
</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
|
||||
v-tooltip.right="formatMessage(messages.lab)"
|
||||
data-onboarding-id="nav-lab"
|
||||
@@ -2475,7 +2577,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
|
||||
<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">
|
||||
{{ formatMessage(messages.userInformation) }}
|
||||
</h3>
|
||||
|
||||
@@ -32,6 +32,908 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
|
||||
]
|
||||
|
||||
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
{
|
||||
id: 'launcher-1.0.2-beta15',
|
||||
version: '1.0.2-beta15',
|
||||
publishedAt: '2026-10-24',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta15',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta15',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the announcement center failing to open and blank pages in the wiki and profile views caused by it.',
|
||||
'zh-CN': '修复公告中心无法打开,以及由此导致的维基与个人资料页面空白。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the skin preview showing garbled textures after switching between players.',
|
||||
'zh-CN': '修复切换玩家后皮肤预览出现纹理错乱的问题。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed instance group changes only showing up after refreshing the page.',
|
||||
'zh-CN': '修复实例分组修改后需要刷新页面才能生效的问题。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed server maps not loading because the map iframe was blocked by the content security policy.',
|
||||
'zh-CN': '修复服务器地图因 iframe 被内容安全策略拦截而无法显示的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta14',
|
||||
version: '1.0.2-beta14',
|
||||
publishedAt: '2026-10-23',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta14',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta14',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Skin-site players can now be created, renamed and deleted, and one can be marked as the default account.',
|
||||
'zh-CN': '皮肤站玩家支持新建、重命名与删除,并可设置默认角色。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'The wiki gained search, recent changes and category browsing, plus page watching and title moves.',
|
||||
'zh-CN': '维基支持搜索、最近更新与分类浏览,页面可关注并修改标题。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Wiki pages now support reading revision history, diffing versions and reverting to an older revision.',
|
||||
'zh-CN': '维基页面支持查看历史修订、版本对比与撤销到指定版本。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Announcements now display the images attached on the skin site.',
|
||||
'zh-CN': '公告中心可显示皮肤站公告附带的图片。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Bug reports now render their attached screenshots instead of file hashes.',
|
||||
'zh-CN': '问题追踪的附件图片改为直接展示,不再显示文件哈希。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US': 'The downloads page shows a sign-in prompt instead of an error when signed out.',
|
||||
'zh-CN': '未登录时资源下载页改为提示登录,不再弹出错误。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed the downloads page reporting a sign-in error even when already signed in.',
|
||||
'zh-CN': '修复资源下载页在已登录状态下仍提示未登录的问题。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the admin panel missing user permission groups, showing UUIDs instead of names for announcement authors, and showing option IDs in review answers.',
|
||||
'zh-CN':
|
||||
'修复管理面板不显示用户权限组、公告发布人显示为 UUID、审核作答显示选项 ID 的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed the wiki opening a blank page and internal links not navigating correctly.',
|
||||
'zh-CN': '修复维基首次打开为空白、内部链接跳转异常的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Skin-site player login errors now surface the server-provided reason.',
|
||||
'zh-CN': '皮肤站玩家登录失败时改为显示服务端给出的具体原因。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed server maps being blocked by CSP and font commands missing permission.',
|
||||
'zh-CN': '修复服务器地图被 CSP 拦截、字体命令缺少权限的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Backups now support instances that use an external game directory.',
|
||||
'zh-CN': '备份支持使用外部游戏目录的实例。',
|
||||
},
|
||||
{
|
||||
'en-US': 'The online-player tooltip now renders MOTD colour codes.',
|
||||
'zh-CN': '在线玩家提示可渲染 MOTD 颜色代码。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed captcha images being cropped and missing the rotation hint.',
|
||||
'zh-CN': '修复验证码图片被裁切、缺少旋转提示的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'The skin-site player list now retries automatically and shows an error state after failures.',
|
||||
'zh-CN': '皮肤站玩家列表加载失败后自动重试,并显示错误状态。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta13',
|
||||
version: '1.0.2-beta13',
|
||||
publishedAt: '2026-10-21',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta13',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta13',
|
||||
},
|
||||
changes: {
|
||||
changed: [
|
||||
{
|
||||
'en-US':
|
||||
'The redesigned home page brings together the 3D skin preview, skin site and Microsoft player selection, announcements, donation acknowledgements and a compact navigation toolbar.',
|
||||
'zh-CN':
|
||||
'新版首页集中展示 3D 皮肤预览、皮肤站与微软玩家切换、公告、捐赠鸣谢及底部功能工具栏。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'The home page keeps the current 3D skin preview stable when accounts or avatars refresh in the background, avoiding unnecessary texture reloads and model flicker.',
|
||||
'zh-CN':
|
||||
'后台刷新账号或头像时保持当前 3D 皮肤预览稳定,避免无关刷新导致纹理重新加载和模型闪烁。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta12',
|
||||
version: '1.0.2-beta12',
|
||||
publishedAt: '2026-10-21',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta12',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta12',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed automatic updates ignoring server rollbacks to an earlier launcher version.',
|
||||
'zh-CN': '修复启动器忽略服务端旧版本回滚的问题,回滚后可自动下载并在退出或重启时安装。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed outdated update checks and downloads overriding a newly selected update version.',
|
||||
'zh-CN': '修复旧的更新检查或下载结果覆盖新目标版本的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
notes: {
|
||||
'en-US':
|
||||
'Install this version once to enable automatic rollback support for subsequent updates.',
|
||||
'zh-CN': '需要先安装一次此版本,之后才能自动跟随服务端回滚。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta11',
|
||||
version: '1.0.2-beta11',
|
||||
publishedAt: '2026-10-20',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta11',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta11',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Filled missing Chinese translations across skin site sign-in, announcements, verification, registration, account settings, downloads and maps, and localized remaining user and Wiki labels.',
|
||||
'zh-CN':
|
||||
'补齐皮肤站登录、公告、验证、注册、账号设置、下载和地图等界面的中文翻译,并修复用户信息与维基权限选项中的英文标签。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Renew the saved skin site JWT on launcher startup before loading the user and announcements. Fixed overlapping refresh, sign-in and sign-out requests that could leave the account in an incorrect state.',
|
||||
'zh-CN':
|
||||
'启动启动器时先刷新已保存的皮肤站 JWT,再加载用户与公告;修复刷新、登录和退出请求交错时可能导致的账号状态异常。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Announcements now reload after sign-in, show a sign-in hint when signed out, and offer a retry action after a loading failure.',
|
||||
'zh-CN': '公告在登录后自动重新加载,未登录时显示登录提示,加载失败时可直接重试。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US':
|
||||
'Skin site release publishing now uses chunked uploads and reports synchronization failures explicitly. Localization checks also scan message IDs used in source to catch missing catalog entries.',
|
||||
'zh-CN':
|
||||
'皮肤站自动发布改用分片上传,同步失败会明确报错;国际化检查新增源码词条扫描,防止再次遗漏语言包内容。',
|
||||
},
|
||||
],
|
||||
},
|
||||
notes: {
|
||||
'en-US':
|
||||
'If the previously saved JWT has already expired, sign in again once to restore the session.',
|
||||
'zh-CN': '如果此前保存的 JWT 已经过期,需要重新登录一次以恢复会话。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta10',
|
||||
version: '1.0.2-beta10',
|
||||
publishedAt: '2026-10-20',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta10',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta10',
|
||||
},
|
||||
changes: {
|
||||
security: [
|
||||
{
|
||||
'en-US':
|
||||
'Backported the upstream Axolotl modpack path traversal fix to prevent malicious modpacks from writing files outside the intended installation directory.',
|
||||
'zh-CN':
|
||||
'合入上游 Axolotl 整合包路径穿越安全补丁,阻止恶意整合包向安装目录之外写入文件。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Strengthened path validation for Modrinth and CurseForge modpacks, server installations, cached files and modpack uninstall cleanup, including unsafe Windows path aliases.',
|
||||
'zh-CN':
|
||||
'加强 Modrinth 与 CurseForge 整合包、服务端安装、缓存文件及整合包卸载清理的路径校验,拦截不安全的 Windows 路径别名。',
|
||||
},
|
||||
],
|
||||
},
|
||||
notes: {
|
||||
'en-US':
|
||||
'Users of earlier versions should update promptly, especially before installing modpacks from untrusted sources.',
|
||||
'zh-CN': '建议旧版本用户尽快更新,尤其是在安装来源不明的整合包之前。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta9',
|
||||
version: '1.0.2-beta9',
|
||||
publishedAt: '2026-10-19',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta9',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta9',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Finished the skin site admin console: system overview, announcements, user and player management, review paper editor, mod upload with batch operations, chat room management with real-time voice, servers, audit log, resource downloads, donations and launcher releases.',
|
||||
'zh-CN':
|
||||
'补全皮肤站管理台:系统概览、公告、用户与玩家管理、审核试卷编辑器、Mod 上传与批量操作、聊天室房间管理与实时语音、服务器、审计日志、资源下载、捐赠与启动器版本管理。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Added Wiki management to the admin console, including a MediaWiki migration center that imports pages from an existing wiki and lets you review, publish or roll back the result.',
|
||||
'zh-CN':
|
||||
'管理台新增维基管理,包含 MediaWiki 迁移中心:可从现有 Wiki 导入页面,并对结果进行审核、发布或回滚。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Added a read-only Wiki reader in the launcher: render pages, follow links between entries, and keep a back stack while browsing.',
|
||||
'zh-CN': '启动器新增维基阅读页:渲染词条正文、跟随词条链接跳转,并在浏览时保留返回栈。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US':
|
||||
'Releases are now published to the skin site automatically when a tag is pushed, so the download page stays in sync without a manual upload.',
|
||||
'zh-CN': '推送 tag 发版时会自动把版本同步到皮肤站,下载页无需再手动上传即可保持同步。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Server management pages are only shown to web administrators.',
|
||||
'zh-CN': '服务器管理页仅对网页管理员展示。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed the Chinese and English text on the skin site home navigation cards.',
|
||||
'zh-CN': '修复皮肤站主页导航卡片的中英文案。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Renamed the public download and map endpoints to resolve a duplicate export.',
|
||||
'zh-CN': '重命名公开下载与地图接口,解决导出冲突。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta8',
|
||||
version: '1.0.2-beta8',
|
||||
publishedAt: '2026-10-18',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta8',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta8',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'The skin site is now fully native inside the launcher: sign-in, registration, password reset, account settings, players, downloads, world map and player review all run through the launcher UI instead of an embedded web page.',
|
||||
'zh-CN':
|
||||
'皮肤站全面改为启动器原生界面:登录、注册、找回密码、账号设置、角色管理、资源下载、服务器地图与玩家审核均不再依赖内嵌网页。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Added a bug tracker: browse and filter issues, read details and comments, post replies, create new issues and follow notifications.',
|
||||
'zh-CN':
|
||||
'新增问题追踪:可浏览与筛选问题、查看详情与评论、发表回复、提交新问题并接收通知。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Added an admin console with a permission gate: system overview, announcements, users, players, player review and launcher releases.',
|
||||
'zh-CN':
|
||||
'新增管理控制台(带权限校验):系统概览、公告管理、用户管理、玩家管理、玩家审核与启动器版本管理。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US':
|
||||
'Hosted modpack downloads now go through the dedicated download server, keeping large downloads off the main API host.',
|
||||
'zh-CN': '整合包下载改为走独立的下载服务器,避免大文件下载挤占主 API 服务。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'LogShare now collects logs with a lightweight reader, fixing crash log uploads that previously failed.',
|
||||
'zh-CN': 'LogShare 改用轻量日志收集,修复崩溃日志上传失败的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'The onboarding tour now scrolls each step into view when the step changes.',
|
||||
'zh-CN': '引导教程在切换步骤时自动滚动到目标元素。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed a type mismatch while collecting crash context credentials.',
|
||||
'zh-CN': '修复崩溃上下文凭据收集中的类型不匹配问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta7',
|
||||
version: '1.0.2-beta7',
|
||||
publishedAt: '2026-10-17',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta7',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta7',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed real-time log backlog that could stall the console under heavy output.',
|
||||
'zh-CN': '修复实时日志积压导致控制台卡顿的问题。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'The update channel now defaults to the build type: installing a Beta build starts on the Beta channel instead of Release.',
|
||||
'zh-CN':
|
||||
'更新通道默认值改为跟随安装包版本:安装 Beta 版后默认使用 Beta 通道,不再回落为 Release。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'On Linux, the default game directory for StarLight instances no longer points at the system install path (e.g. /usr/bin); it falls back to the home directory.',
|
||||
'zh-CN':
|
||||
'Linux 上 StarLight 实例的默认游戏目录不再指向系统安装路径(如 /usr/bin),改为回退到用户主目录。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta6',
|
||||
version: '1.0.2-beta6',
|
||||
publishedAt: '2026-10-11',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta6',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta6',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Added keyboard shortcut settings: enable and rebind navigation and quick-scrolling shortcuts.',
|
||||
'zh-CN': '新增键盘快捷键设置:可启用并自定义导航与快速滚动快捷键。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Added crash log sharing and AI analysis: share crash diagnostics to LogShare for a structured summary and optional AI analysis, with a rebuilt crash window that browses log files and streams the analysis.',
|
||||
'zh-CN':
|
||||
'新增崩溃日志分享与 AI 分析:可将崩溃诊断分享到 LogShare 获取结构化摘要与 AI 分析;崩溃窗口重做为双栏布局,可浏览日志文件并流式查看分析结果。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Crash logs now also collect JVM crash reports (hs_err_pid*.log) and launcher logs.',
|
||||
'zh-CN': '崩溃日志现在也会收集 JVM 崩溃报告(hs_err_pid*.log)与启动器日志。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Added a launcher log export entry.',
|
||||
'zh-CN': '新增启动器日志导出入口。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Instance groups can now be reordered by dragging.',
|
||||
'zh-CN': '实例分组支持拖拽重排。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US': 'Synced the latest supported AI chat model list from LobeHub.',
|
||||
'zh-CN': '同步 LobeHub 最新的 AI 聊天模型列表。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed remote announcements not refreshing.',
|
||||
'zh-CN': '修复远端公告不刷新的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Completed the Chinese translations for the armor preview feature.',
|
||||
'zh-CN': '补齐盔甲预览功能的中文翻译。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta5',
|
||||
version: '1.0.2-beta5',
|
||||
publishedAt: '2026-10-11',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta5',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta5',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.',
|
||||
'zh-CN':
|
||||
'新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.',
|
||||
'zh-CN':
|
||||
'新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Completed the Chinese translations for the armor preview feature.',
|
||||
'zh-CN': '补齐盔甲预览功能的中文翻译。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta4',
|
||||
version: '1.0.2-beta4',
|
||||
publishedAt: '2026-10-08',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta4',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta4',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
|
||||
'zh-CN':
|
||||
'皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
|
||||
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US':
|
||||
'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
|
||||
'zh-CN':
|
||||
'优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
|
||||
'zh-CN':
|
||||
'修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Fixed instance group changes not saving for some instances.',
|
||||
'zh-CN': '修复部分实例的分组修改无法保存的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta3',
|
||||
version: '1.0.2-beta3',
|
||||
publishedAt: '2026-10-04',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta3',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta3',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
|
||||
'zh-CN':
|
||||
'修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta2',
|
||||
version: '1.0.2-beta2',
|
||||
publishedAt: '2026-10-04',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta2',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta2',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
|
||||
'zh-CN':
|
||||
'修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta1',
|
||||
version: '1.0.2-beta1',
|
||||
publishedAt: '2026-10-04',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta1',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta1',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
|
||||
'zh-CN':
|
||||
'跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
|
||||
'zh-CN':
|
||||
'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Added a developer feature flag to preview the anniversary celebration at any time.',
|
||||
'zh-CN': '新增开发者功能开关,可随时预览周年庆效果。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -99,15 +1001,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
'zh-CN': '修复更新清单的下载地址与发布顺序,更新检查现在可以成功。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Updated the signing key pair used to sign update packages.',
|
||||
'en-US': 'Updated the signing key pair used to sign update packages.',
|
||||
'zh-CN': '更新了用于签名更新包的密钥对。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US':
|
||||
'Removed upstream release announcements prior to 1.0.0-beta1.',
|
||||
'en-US': 'Removed upstream release announcements prior to 1.0.0-beta1.',
|
||||
'zh-CN': '移除了 1.0.0-beta1 之前的上游版本公告。',
|
||||
},
|
||||
],
|
||||
@@ -124,13 +1024,11 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Installing a modpack now lets you choose the game directory.',
|
||||
'en-US': 'Installing a modpack now lets you choose the game directory.',
|
||||
'zh-CN': '一键安装整合包时现在可以选择游戏目录。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Approved (hosted) modpack sync and StarLight instance mode were added.',
|
||||
'en-US': 'Approved (hosted) modpack sync and StarLight instance mode were added.',
|
||||
'zh-CN': '新增获批整合包同步与 StarLight 实例模式。',
|
||||
},
|
||||
{
|
||||
@@ -141,8 +1039,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US':
|
||||
'The about page now plays a looping wither victory animation.',
|
||||
'en-US': 'The about page now plays a looping wither victory animation.',
|
||||
'zh-CN': '关于页现在会播放循环的凋灵胜利动画。',
|
||||
},
|
||||
],
|
||||
@@ -154,8 +1051,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
'修复从 .minecraft 文件夹导入实例的多项问题(内容复制、版本隔离、符号链接、加载器版本识别等)。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the instance log view stopping updates and overlapping log lines.',
|
||||
'en-US': 'Fixed the instance log view stopping updates and overlapping log lines.',
|
||||
'zh-CN': '修复实例日志窗口停更与日志行重叠的问题。',
|
||||
},
|
||||
{
|
||||
@@ -174,8 +1070,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
'zh-CN': '修复未登录皮肤站时启动卡住且不再弹出玩家选择的问题。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed various skin site login, player list sync, and display issues.',
|
||||
'en-US': 'Fixed various skin site login, player list sync, and display issues.',
|
||||
'zh-CN': '修复皮肤站登录、玩家列表同步及若干显示问题。',
|
||||
},
|
||||
],
|
||||
@@ -194,7 +1089,8 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
{
|
||||
'en-US':
|
||||
'The launcher has been rebranded from Axolotl Launcher to Starlight Launcher, with a new icon and a refreshed about page.',
|
||||
'zh-CN': '启动器已从 Axolotl Launcher 更名为 Starlight Launcher,并更换了全新图标与关于页。',
|
||||
'zh-CN':
|
||||
'启动器已从 Axolotl Launcher 更名为 Starlight Launcher,并更换了全新图标与关于页。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
@@ -208,7 +1104,8 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
],
|
||||
added: [
|
||||
{
|
||||
'en-US': 'A "Starlight" entry was added to the sidebar, which embeds the StarLight skin site.',
|
||||
'en-US':
|
||||
'A "Starlight" entry was added to the sidebar, which embeds the StarLight skin site.',
|
||||
'zh-CN': '侧边栏新增「斯达莱特」入口,内嵌 StarLight 皮肤站。',
|
||||
},
|
||||
{
|
||||
@@ -227,10 +1124,12 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
{
|
||||
'en-US':
|
||||
'The hard-coded Starlight official server was removed from "Pinned servers"; only servers you favorite yourself are shown now.',
|
||||
'zh-CN': '移除了「固定的服务器」中写死的 Starlight 官方服务器,现在只显示你自己收藏的服务器。',
|
||||
'zh-CN':
|
||||
'移除了「固定的服务器」中写死的 Starlight 官方服务器,现在只显示你自己收藏的服务器。',
|
||||
},
|
||||
{
|
||||
'en-US': 'The swimming axolotl animation on the about page was replaced with a static nether star.',
|
||||
'en-US':
|
||||
'The swimming axolotl animation on the about page was replaced with a static nether star.',
|
||||
'zh-CN': '关于页中游动的美西螈动画已替换为静止的下界之星。',
|
||||
},
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
@@ -201,7 +201,7 @@ onUnmounted(() => {
|
||||
data-onboarding-id="home-instances"
|
||||
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" />
|
||||
|
||||
<template v-if="selectedInstance">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||
import SkinSiteLoginModal from '@/components/ui/skin-site/SkinSiteLoginModal.vue'
|
||||
import {
|
||||
openSkinSiteLogin,
|
||||
requestSkinSitePlayers,
|
||||
skinSitePlayers,
|
||||
skinSiteStatus,
|
||||
@@ -28,7 +27,7 @@ const { formatMessage } = useVIntl()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
|
||||
const router = useRouter()
|
||||
const skinSiteLoginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
|
||||
const accounts = ref<PlayerChoice[]>([])
|
||||
const active = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -137,9 +136,8 @@ function signInSkinSite() {
|
||||
rejectSelection?.(new PlayerSelectionNavigatedAwayError())
|
||||
resolveSelection = undefined
|
||||
rejectSelection = undefined
|
||||
openSkinSiteLogin()
|
||||
skinSiteLoginModal.value?.show()
|
||||
modal.value?.hide()
|
||||
void router.push('/starlight-skin')
|
||||
}
|
||||
|
||||
const unregister = registerInstancePlayerPicker((id, saved) => {
|
||||
@@ -246,5 +244,6 @@ onUnmounted(() => {
|
||||
<p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p>
|
||||
</div>
|
||||
</NewModal>
|
||||
<SkinSiteLoginModal ref="skinSiteLoginModal" @signed-in="refresh" />
|
||||
<MinecraftLoginModal ref="microsoftLogin" @complete="refresh" />
|
||||
</template>
|
||||
@@ -5,12 +5,6 @@ import test from 'node:test'
|
||||
|
||||
import { createRenderer, h, nextTick, Suspense } from 'vue'
|
||||
|
||||
import {
|
||||
receiveSkinSiteMessage,
|
||||
resetSkinSiteSession,
|
||||
setSkinSiteFrame,
|
||||
SKIN_SITE_ORIGIN,
|
||||
} from '../../composables/skin-site-session.ts'
|
||||
import { useNetworkStatus } from '../../composables/useNetworkStatus.ts'
|
||||
|
||||
// Exercise the real component's setup and reactive account list. Only platform,
|
||||
@@ -19,6 +13,7 @@ const require = createRequire(import.meta.url)
|
||||
const { parse, compileScript } = require('vue/compiler-sfc')
|
||||
const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href
|
||||
const stubs: Record<string, string> = {
|
||||
'@tauri-apps/plugin-http': `export const fetch=(...args)=>globalThis.accountTestFetch(...args)`,
|
||||
'@modrinth/assets': `export const CopyIcon={}, LogInIcon={}, PlusIcon={}, RadioButtonCheckedIcon={}, RadioButtonIcon={}, RefreshCwIcon={}, SpinnerIcon={}, TrashIcon={}`,
|
||||
'@modrinth/ui': `export const Accordion={}, Avatar={}, ButtonStyled={}; export const defineMessages=x=>x; export const injectNotificationManager=()=>({handleError:()=>{}}); export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})`,
|
||||
'@tanstack/vue-query': `export const useQueryClient=()=>({refetchQueries:async()=>{}})`,
|
||||
@@ -26,6 +21,7 @@ const stubs: Record<string, string> = {
|
||||
'vue-router': `export const useRoute=()=>({fullPath:'/'}); export const useRouter=()=>({push:async()=>{}})`,
|
||||
'@/assets/netherstar.png': `export default 'logo'`,
|
||||
'@/components/ui/MinecraftLoginModal.vue': `export default {}`,
|
||||
'@/components/ui/skin-site/SkinSiteLoginModal.vue': `export default {}`,
|
||||
'@/helpers/events': `export const process_listener=async()=>()=>{}`,
|
||||
'@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`,
|
||||
'@/helpers/skins': `export const get_available_skins=async()=>[]`,
|
||||
@@ -59,6 +55,13 @@ const hooks = registerHooks({
|
||||
},
|
||||
})
|
||||
const { default: AccountsCard } = await import(componentUrl)
|
||||
const {
|
||||
resetSkinSiteSession,
|
||||
signInSkinSite,
|
||||
signOutSkinSite,
|
||||
selectedSkinSitePlayerId,
|
||||
SKIN_SITE_ORIGIN,
|
||||
} = await import('../../composables/skin-site-session.ts')
|
||||
hooks.deregister()
|
||||
|
||||
type TestNode = Record<string, unknown>
|
||||
@@ -72,6 +75,7 @@ type CardState = {
|
||||
defaultUser: string | undefined
|
||||
selectedAccount: TestAccount | undefined
|
||||
setAccount: (account: TestAccount) => Promise<void>
|
||||
setSkinSitePlayer: (id: string) => Promise<void>
|
||||
getAccountAvatarUrl: (account: TestAccount) => string | undefined
|
||||
}
|
||||
const renderer = createRenderer<TestNode, TestNode>({
|
||||
@@ -93,37 +97,39 @@ const flush = async () => {
|
||||
const firstId = '12345678-1234-1234-1234-123456789abc'
|
||||
const secondId = '22345678-1234-1234-1234-123456789abc'
|
||||
|
||||
async function fixture(hasOfficial = true) {
|
||||
async function fixture(hasOfficial = true, officialId = 'official', linkedMojang = false) {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||
const stored: TestAccount[] = hasOfficial
|
||||
? [{ account_type: 'microsoft', profile: { id: 'official', name: 'Official' } }]
|
||||
? [{ account_type: 'microsoft', profile: { id: officialId, name: 'Official' } }]
|
||||
: []
|
||||
let selected: string | undefined = hasOfficial ? 'official' : undefined
|
||||
let selected: string | undefined = hasOfficial ? officialId : undefined
|
||||
let rejectLogin = false
|
||||
let loginCalls = 0
|
||||
const frame = {
|
||||
postMessage(data: { type: string; requestId: string }) {
|
||||
if (data.type === 'starlight-pack-token-request')
|
||||
message({
|
||||
type: 'starlight-pack-token-result',
|
||||
requestId: data.requestId,
|
||||
token: 'fixture.jwt',
|
||||
})
|
||||
if (data.type === 'starlight-skin-players-request')
|
||||
message({
|
||||
type: 'starlight-skin-players-result',
|
||||
requestId: data.requestId,
|
||||
ok: true,
|
||||
players: [
|
||||
{ uuid: firstId, name: 'First', isMojang: false, skinState: 'empty' },
|
||||
{ uuid: secondId, name: 'Second', isMojang: false, skinState: 'empty' },
|
||||
],
|
||||
})
|
||||
const storage = new Map<string, string>()
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
},
|
||||
} as unknown as Window
|
||||
function message(data: unknown) {
|
||||
receiveSkinSiteMessage({ origin: SKIN_SITE_ORIGIN, source: frame, data } as MessageEvent, frame)
|
||||
})
|
||||
;(
|
||||
globalThis as unknown as { accountTestFetch: (url: string) => Promise<Response> }
|
||||
).accountTestFetch = async (url) => {
|
||||
const path = new URL(url).pathname
|
||||
const payload = path.endsWith('/login')
|
||||
? { username: 'Owner', jwt: 'fixture.jwt' }
|
||||
: path.endsWith('/user')
|
||||
? { uuid: 'owner', username: 'Owner', permissions: [] }
|
||||
: path.endsWith('/player')
|
||||
? [
|
||||
{ uuid: firstId, name: 'First', isMojang: linkedMojang || firstId === officialId },
|
||||
{ uuid: secondId, name: 'Second', isMojang: false },
|
||||
]
|
||||
: {}
|
||||
return Response.json({ payload })
|
||||
}
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
|
||||
useNetworkStatus().refreshBrowserOffline()
|
||||
@@ -163,22 +169,18 @@ async function fixture(hasOfficial = true) {
|
||||
},
|
||||
})
|
||||
resetSkinSiteSession()
|
||||
setSkinSiteFrame(frame)
|
||||
const node = h({ ...AccountsCard, render: () => null })
|
||||
const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) })
|
||||
app.mount({})
|
||||
await flush()
|
||||
return {
|
||||
state: (node.component! as unknown as { setupState: CardState }).setupState,
|
||||
login() {
|
||||
message({
|
||||
type: 'starlight-skin-session',
|
||||
status: 'signed-in',
|
||||
user: { uuid: 'owner', username: 'Owner' },
|
||||
})
|
||||
async login() {
|
||||
await signInSkinSite({ username: 'Owner', password: 'password', clientId: 'test' })
|
||||
await flush()
|
||||
},
|
||||
signOut() {
|
||||
message({ type: 'starlight-skin-session', status: 'signed-out' })
|
||||
signOutSkinSite()
|
||||
},
|
||||
failLogin(value: boolean) {
|
||||
rejectLogin = value
|
||||
@@ -192,7 +194,7 @@ async function fixture(hasOfficial = true) {
|
||||
close() {
|
||||
app.unmount()
|
||||
resetSkinSiteSession()
|
||||
setSkinSiteFrame(null)
|
||||
Reflect.deleteProperty(globalThis, 'localStorage')
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
|
||||
else Reflect.deleteProperty(globalThis, 'window')
|
||||
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
|
||||
@@ -205,7 +207,7 @@ test('sidebar offers every skin-site player before any game authentication or la
|
||||
const view = await fixture(false)
|
||||
try {
|
||||
view.failLogin(true)
|
||||
view.login()
|
||||
await view.login()
|
||||
await flush()
|
||||
assert.deepEqual(
|
||||
view.state.accounts.map((account) => account.profile.id),
|
||||
@@ -221,7 +223,7 @@ test('sidebar offers every skin-site player before any game authentication or la
|
||||
test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => {
|
||||
const view = await fixture()
|
||||
try {
|
||||
view.login()
|
||||
await view.login()
|
||||
await flush()
|
||||
await view.state.setAccount(
|
||||
view.state.accounts.find((account) => account.profile.id === secondId)!,
|
||||
@@ -238,7 +240,7 @@ test('choosing an unused skin-site player authenticates and persists the default
|
||||
test('failed player authentication preserves the default and allows retry', async () => {
|
||||
const view = await fixture()
|
||||
try {
|
||||
view.login()
|
||||
await view.login()
|
||||
await flush()
|
||||
view.failLogin(true)
|
||||
await view.state.setAccount(
|
||||
@@ -259,7 +261,7 @@ test('failed player authentication preserves the default and allows retry', asyn
|
||||
test('signing out removes unregistered choices while keeping saved accounts', async () => {
|
||||
const view = await fixture()
|
||||
try {
|
||||
view.login()
|
||||
await view.login()
|
||||
await flush()
|
||||
await view.state.setAccount(
|
||||
view.state.accounts.find((account) => account.profile.id === firstId)!,
|
||||
@@ -271,7 +273,7 @@ test('signing out removes unregistered choices while keeping saved accounts', as
|
||||
[firstId, 'official'],
|
||||
)
|
||||
assert.equal(view.selected, firstId)
|
||||
view.login()
|
||||
await view.login()
|
||||
await flush()
|
||||
assert.equal(view.state.accounts.length, 3)
|
||||
assert.equal(view.loginCalls, 1)
|
||||
@@ -283,10 +285,71 @@ test('signing out removes unregistered choices while keeping saved accounts', as
|
||||
test('a new player without a skin can render the shared empty avatar', async () => {
|
||||
const view = await fixture(false)
|
||||
try {
|
||||
view.login()
|
||||
await view.login()
|
||||
await flush()
|
||||
assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined)
|
||||
} finally {
|
||||
view.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('choosing a player from the home avatar rail switches the persisted account and shared skin selection', async () => {
|
||||
const view = await fixture()
|
||||
try {
|
||||
await view.login()
|
||||
await view.state.setSkinSitePlayer(secondId)
|
||||
assert.equal(view.selected, secondId)
|
||||
assert.equal(view.state.selectedAccount?.profile.id, secondId)
|
||||
assert.equal(selectedSkinSitePlayerId.value, secondId)
|
||||
await view.state.setAccount(
|
||||
view.state.accounts.find((account) => account.profile.id === 'official')!,
|
||||
)
|
||||
assert.equal(view.selected, 'official')
|
||||
assert.equal(selectedSkinSitePlayerId.value, null)
|
||||
} finally {
|
||||
view.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('failed home player selection keeps the current skin selection as well as the account', async () => {
|
||||
const view = await fixture()
|
||||
try {
|
||||
await view.login()
|
||||
await view.state.setSkinSitePlayer(firstId)
|
||||
view.failLogin(true)
|
||||
await view.state.setSkinSitePlayer(secondId)
|
||||
assert.equal(view.selected, firstId)
|
||||
assert.equal(selectedSkinSitePlayerId.value, firstId)
|
||||
} finally {
|
||||
view.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a Microsoft player linked on the skin site keeps official skin and cape management', async () => {
|
||||
const view = await fixture(true, firstId)
|
||||
try {
|
||||
await view.login()
|
||||
assert.equal(selectedSkinSitePlayerId.value, null)
|
||||
await view.state.setSkinSitePlayer(secondId)
|
||||
await view.state.setAccount(
|
||||
view.state.accounts.find((account) => account.profile.id === firstId)!,
|
||||
)
|
||||
assert.equal(view.selected, firstId)
|
||||
assert.equal(selectedSkinSitePlayerId.value, null)
|
||||
} finally {
|
||||
view.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('site players linked to Mojang remain selectable without a saved Microsoft credential', async () => {
|
||||
const view = await fixture(false, 'official', true)
|
||||
try {
|
||||
await view.login()
|
||||
assert.equal(view.state.accounts.length, 2)
|
||||
await view.state.setSkinSitePlayer(firstId)
|
||||
assert.equal(view.selected, firstId)
|
||||
assert.equal(selectedSkinSitePlayerId.value, firstId)
|
||||
} finally {
|
||||
view.close()
|
||||
}
|
||||
})
|
||||
@@ -244,6 +244,7 @@
|
||||
</div>
|
||||
</Accordion>
|
||||
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
|
||||
<SkinSiteLoginModal ref="skinSiteLoginModal" @signed-in="onSkinSiteSignedIn" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -269,12 +270,12 @@ import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import axolotlLogo from '@/assets/netherstar.png'
|
||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||
import SkinSiteLoginModal from '@/components/ui/skin-site/SkinSiteLoginModal.vue'
|
||||
import {
|
||||
openSkinSiteLogin,
|
||||
requestSkinSitePlayers,
|
||||
selectedSkinSitePlayerId,
|
||||
selectSkinSitePlayer,
|
||||
@@ -298,21 +299,20 @@ import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Skin } from '@/helpers/skins'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
|
||||
const queryClient = useQueryClient()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const themeStore = useTheming()
|
||||
const skinSiteLoginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
|
||||
|
||||
async function goToSkinSiteLogin() {
|
||||
openSkinSiteLogin()
|
||||
// The login action must reveal the iframe even if Minimal Home was selected.
|
||||
themeStore.homeLayout = 'standard'
|
||||
await router.push('/').catch(handleError)
|
||||
skinSiteLoginModal.value?.show()
|
||||
}
|
||||
|
||||
function onSkinSiteSignedIn() {
|
||||
void requestSkinSitePlayers().catch(() => {})
|
||||
}
|
||||
const refreshingNetwork = ref(false)
|
||||
|
||||
@@ -373,7 +373,6 @@ const accounts = computed(() => {
|
||||
)
|
||||
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
|
||||
for (const player of skinSitePlayers.value) {
|
||||
if (player.isMojang) continue
|
||||
const id = player.uuid.replaceAll('-', '').toLowerCase()
|
||||
const stored = byId.get(id)
|
||||
if (stored && stored.account_type !== 'yggdrasil') continue
|
||||
@@ -409,8 +408,12 @@ async function refreshSkinSitePlayers() {
|
||||
await requestSkinSitePlayers().catch(() => {})
|
||||
}
|
||||
|
||||
function setSkinSitePlayer(playerId: string) {
|
||||
selectSkinSitePlayer(playerId)
|
||||
async function setSkinSitePlayer(playerId: string) {
|
||||
const normalizedId = playerId.replaceAll('-', '').toLowerCase()
|
||||
const account = accounts.value.find(
|
||||
(candidate) => candidate.profile.id.replaceAll('-', '').toLowerCase() === normalizedId,
|
||||
)
|
||||
if (account) await setAccount(account)
|
||||
}
|
||||
|
||||
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
|
||||
@@ -531,6 +534,12 @@ function setLoginDisabled(value: boolean) {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
accounts,
|
||||
defaultUser,
|
||||
accountSelectionPending,
|
||||
setAccount,
|
||||
getAccountSkin,
|
||||
getAccountAvatarUrl,
|
||||
accountChangeRevision,
|
||||
login,
|
||||
refreshValues,
|
||||
@@ -669,7 +678,15 @@ async function setAccount(account: MinecraftCredential) {
|
||||
})
|
||||
}
|
||||
await persistDefaultUser(userId)
|
||||
selectSkinSitePlayer(null)
|
||||
const sitePlayer =
|
||||
account.account_type === 'yggdrasil'
|
||||
? skinSitePlayers.value.find(
|
||||
(player) =>
|
||||
player.uuid.replaceAll('-', '').toLowerCase() ===
|
||||
userId.replaceAll('-', '').toLowerCase(),
|
||||
)
|
||||
: undefined
|
||||
selectSkinSitePlayer(sitePlayer?.uuid ?? null)
|
||||
defaultUser.value = userId
|
||||
equippedSkin.value = null
|
||||
notifyAccountChange()
|
||||
@@ -682,9 +699,20 @@ async function setAccount(account: MinecraftCredential) {
|
||||
}
|
||||
|
||||
watch(
|
||||
[skinSitePlayers, defaultUser, skinSiteUser],
|
||||
[skinSitePlayers, defaultUser, skinSiteUser, accounts],
|
||||
([availablePlayers, selectedLocalUser]) => {
|
||||
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
|
||||
if (selectedLocalUser) {
|
||||
const account = accounts.value.find((candidate) => candidate.profile.id === selectedLocalUser)
|
||||
const match =
|
||||
account?.account_type === 'yggdrasil'
|
||||
? availablePlayers.find(
|
||||
(player) =>
|
||||
player.uuid.replaceAll('-', '').toLowerCase() ===
|
||||
selectedLocalUser.replaceAll('-', '').toLowerCase(),
|
||||
)
|
||||
: undefined
|
||||
selectSkinSitePlayer(match?.uuid ?? null)
|
||||
} else if (!selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
|
||||
selectSkinSitePlayer(availablePlayers[0].uuid)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { anniversaryYear, ordinal, shouldCelebrate } from '@/helpers/anniversary'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
|
||||
const messages = defineMessages({
|
||||
banner: {
|
||||
id: 'app.anniversary.banner',
|
||||
defaultMessage: 'Happy {ordinal} Anniversary, Starlight!',
|
||||
},
|
||||
})
|
||||
|
||||
/** Emoji that drift down from the top of the window. */
|
||||
const CONFETTI_EMOJI = ['🎂', '🎉', '🎊', '🎈', '✨', '🧁', '🍰']
|
||||
|
||||
type Confetti = {
|
||||
id: number
|
||||
emoji: string
|
||||
left: number
|
||||
delay: number
|
||||
duration: number
|
||||
size: number
|
||||
drift: number
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
const years = ref(0)
|
||||
const confetti = ref<Confetti[]>([])
|
||||
let spawnTimer: number | undefined
|
||||
let nextId = 0
|
||||
|
||||
function spawnConfetti(): void {
|
||||
const emoji = CONFETTI_EMOJI[Math.floor(Math.random() * CONFETTI_EMOJI.length)]
|
||||
confetti.value.push({
|
||||
id: nextId++,
|
||||
emoji,
|
||||
left: Math.random() * 100,
|
||||
delay: 0,
|
||||
duration: 6 + Math.random() * 5,
|
||||
size: 1 + Math.random() * 1.4,
|
||||
drift: (Math.random() - 0.5) * 160,
|
||||
})
|
||||
// Keep the DOM small; drop the oldest once we exceed a cap.
|
||||
if (confetti.value.length > 60) confetti.value.splice(0, 10)
|
||||
}
|
||||
|
||||
const forced = computed(() => themeStore.getFeatureFlag('force_anniversary_celebration'))
|
||||
|
||||
function startCelebration(): void {
|
||||
if (spawnTimer !== undefined) return
|
||||
years.value = anniversaryYear()
|
||||
visible.value = true
|
||||
// Seed a few so the screen is not empty on the first frame.
|
||||
for (let i = 0; i < 12; i++) spawnConfetti()
|
||||
spawnTimer = window.setInterval(spawnConfetti, 700)
|
||||
}
|
||||
|
||||
function stopCelebration(): void {
|
||||
if (spawnTimer !== undefined) {
|
||||
window.clearInterval(spawnTimer)
|
||||
spawnTimer = undefined
|
||||
}
|
||||
confetti.value = []
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => shouldCelebrate(forced.value),
|
||||
(active) => (active ? startCelebration() : stopCelebration()),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(stopCelebration)
|
||||
|
||||
const bannerText = computed(() =>
|
||||
formatMessage(messages.banner, { ordinal: ordinal(years.value) }),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="anniversary-fade">
|
||||
<div v-if="visible" class="anniversary-root" aria-live="polite">
|
||||
<!-- Falling confetti layer (click-through). -->
|
||||
<div class="anniversary-confetti" aria-hidden="true">
|
||||
<span
|
||||
v-for="piece in confetti"
|
||||
:key="piece.id"
|
||||
class="anniversary-piece"
|
||||
:style="{
|
||||
left: piece.left + '%',
|
||||
fontSize: piece.size + 'rem',
|
||||
animationDelay: piece.delay + 's',
|
||||
animationDuration: piece.duration + 's',
|
||||
'--drift': piece.drift + 'px',
|
||||
}"
|
||||
>
|
||||
{{ piece.emoji }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Top banner. -->
|
||||
<div class="anniversary-banner">
|
||||
<span class="anniversary-banner-emoji">🎂</span>
|
||||
<span class="anniversary-banner-text">{{ bannerText }}</span>
|
||||
<span class="anniversary-banner-emoji">🎉</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.anniversary-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Above content, below modals/drag overlays (which use z-[200]). */
|
||||
z-index: 150;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.anniversary-confetti {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.anniversary-piece {
|
||||
position: absolute;
|
||||
top: -3rem;
|
||||
will-change: transform;
|
||||
animation-name: anniversary-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: 1;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes anniversary-fall {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--drift, 0), 110vh, 0) rotate(360deg);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.anniversary-banner {
|
||||
position: absolute;
|
||||
/* 避开原生窗口标题栏(Windows/macOS 系统装饰条会盖住 top:0 的内容);
|
||||
titlebar-area-height 是 Tauri 暴露的标题栏高度变量,不支持时降级为 0 */
|
||||
top: env(titlebar-area-height, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
/* 半透明深色兜底:即使下面的渐变背景失效,白字也不会在浅色页面上隐形 */
|
||||
background-color: rgba(20, 16, 28, 0.35);
|
||||
/* 半透明彩色渐变:让窗口下方的内容能透出来 */
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(244, 114, 182, 0.8),
|
||||
rgba(250, 204, 21, 0.8),
|
||||
rgba(74, 222, 128, 0.8),
|
||||
rgba(56, 189, 248, 0.8),
|
||||
rgba(167, 139, 250, 0.8),
|
||||
rgba(244, 114, 182, 0.8)
|
||||
);
|
||||
background-size: 300% 100%;
|
||||
animation: anniversary-gradient 8s linear infinite;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||
text-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.55),
|
||||
0 0 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.anniversary-banner-text {
|
||||
animation: anniversary-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.anniversary-banner-emoji {
|
||||
animation: anniversary-bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes anniversary-gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 300% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes anniversary-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes anniversary-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.anniversary-fade-enter-active,
|
||||
.anniversary-fade-leave-active {
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
.anniversary-fade-enter-from,
|
||||
.anniversary-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anniversary-piece,
|
||||
.anniversary-banner,
|
||||
.anniversary-banner-text,
|
||||
.anniversary-banner-emoji {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -67,6 +67,23 @@
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<ButtonStyled
|
||||
v-tooltip="formatMessage(messages.announcements)"
|
||||
type="transparent"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
:aria-label="formatMessage(messages.announcements)"
|
||||
class="relative"
|
||||
@click="openAnnouncementCenter"
|
||||
>
|
||||
<NewspaperIcon />
|
||||
<span
|
||||
v-if="announcementUnreadCount"
|
||||
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
|
||||
color="brand"
|
||||
@@ -190,6 +207,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BellIcon,
|
||||
NewspaperIcon,
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
@@ -211,7 +229,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
@@ -222,6 +240,10 @@ import { get_many as getInstances } from '@/helpers/instance'
|
||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import {
|
||||
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
|
||||
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
|
||||
} from '@/helpers/remote-announcements'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
|
||||
|
||||
@@ -231,6 +253,25 @@ const popupNotificationManager = injectPopupNotificationManager()
|
||||
const downloadManager = injectDownloadManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const announcementUnreadCount = ref(0)
|
||||
|
||||
function openAnnouncementCenter() {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT))
|
||||
}
|
||||
|
||||
function updateAnnouncementCount(event: Event) {
|
||||
const detail = (event as CustomEvent<{ unreadKeys?: string[] }>).detail
|
||||
announcementUnreadCount.value = detail?.unreadKeys?.length ?? 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
|
||||
})
|
||||
|
||||
type NotificationHistoryItem = {
|
||||
key: string
|
||||
createdAt?: number
|
||||
@@ -370,6 +411,10 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
announcements: {
|
||||
id: 'app.action-bar.announcements',
|
||||
defaultMessage: 'Announcements',
|
||||
},
|
||||
clearNotifications: {
|
||||
id: 'app.action-bar.notifications.clear',
|
||||
defaultMessage: 'Clear all',
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,465 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useModalStack,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
announcementKey,
|
||||
isAnnouncementActive,
|
||||
mapBroadcastAnnouncements,
|
||||
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
|
||||
parseAnnouncements,
|
||||
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
|
||||
type RemoteAnnouncement,
|
||||
safeAnnouncementUrl,
|
||||
} from '@/helpers/remote-announcements'
|
||||
|
||||
import {
|
||||
requestSkinSiteDownloadToken,
|
||||
skinSiteStatus,
|
||||
waitForSkinSiteSession,
|
||||
} from '@/composables/skin-site-session.ts'
|
||||
|
||||
const props = defineProps<{ ready: boolean; previewOnly?: boolean }>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { hasModal } = useModalStack()
|
||||
const messages = defineMessages({
|
||||
view: { id: 'app.remote-announcements.view', defaultMessage: 'View announcement' },
|
||||
unread: { id: 'app.remote-announcements.unread', defaultMessage: 'Unread' },
|
||||
readAll: {
|
||||
id: 'app.remote-announcements.read-all',
|
||||
defaultMessage: 'Mark all announcements as read',
|
||||
},
|
||||
centerTitle: { id: 'app.remote-announcements.center-title', defaultMessage: 'Announcements' },
|
||||
empty: { id: 'app.remote-announcements.empty', defaultMessage: 'No announcements' },
|
||||
previewTitle: {
|
||||
id: 'app.remote-announcements.preview-title',
|
||||
defaultMessage: 'Announcement style preview',
|
||||
},
|
||||
previewSummary: {
|
||||
id: 'app.remote-announcements.preview-summary',
|
||||
defaultMessage:
|
||||
'This is a local preview. Select View announcement to preview the full Markdown content.',
|
||||
},
|
||||
previewContent: {
|
||||
id: 'app.remote-announcements.preview-content',
|
||||
defaultMessage:
|
||||
'## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://skin.starlight.cool/)',
|
||||
},
|
||||
previewAction: { id: 'app.remote-announcements.preview-action', defaultMessage: 'Visit website' },
|
||||
})
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const selected = ref<RemoteAnnouncement | null>(null)
|
||||
const active = ref(false)
|
||||
const centerOpen = ref(false)
|
||||
const startupNotice = ref<RemoteAnnouncement | null>(null)
|
||||
const html = computed(() => renderString(selected.value?.content ?? ''))
|
||||
const stateKey = 'starlight-remote-announcements-v2'
|
||||
const read = reactive(new Set<string>())
|
||||
const queuedThisSession = new Set<string>()
|
||||
const startupNotified = new Set<string>()
|
||||
let items: RemoteAnnouncement[] = []
|
||||
let pending: RemoteAnnouncement[] = []
|
||||
let cacheKey = 'starlight-remote-announcements-cache-v1'
|
||||
let cacheLoaded = false
|
||||
let disposed = false
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
let advanceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function persist() {
|
||||
if (props.previewOnly) return
|
||||
try {
|
||||
localStorage.setItem(
|
||||
stateKey,
|
||||
JSON.stringify({
|
||||
read: [...read].slice(-1000),
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode / quota)
|
||||
}
|
||||
}
|
||||
function emitState() {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, {
|
||||
detail: {
|
||||
items,
|
||||
unreadKeys: items.filter((item) => !read.has(announcementKey(item))).map(announcementKey),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
function openCenter(item?: RemoteAnnouncement) {
|
||||
if (!props.ready || disposed) return
|
||||
void refresh()
|
||||
centerOpen.value = true
|
||||
if (item) {
|
||||
startupNotice.value = null
|
||||
selectAnnouncement(item)
|
||||
} else if (!selected.value && items.length) {
|
||||
selectAnnouncement(items[0])
|
||||
}
|
||||
void nextTick(() => modal.value?.show())
|
||||
}
|
||||
function handleOpenCenter() {
|
||||
if (props.previewOnly) return
|
||||
openCenter()
|
||||
}
|
||||
async function show(item: RemoteAnnouncement) {
|
||||
if (
|
||||
disposed ||
|
||||
!props.ready ||
|
||||
!isAnnouncementActive(item) ||
|
||||
(hasModal.value && !active.value && !centerOpen.value)
|
||||
)
|
||||
return
|
||||
centerOpen.value = true
|
||||
active.value = true
|
||||
selectAnnouncement(item)
|
||||
await nextTick()
|
||||
if (!disposed) modal.value?.show()
|
||||
}
|
||||
function selectAnnouncement(item: RemoteAnnouncement) {
|
||||
selected.value = item
|
||||
const key = announcementKey(item)
|
||||
read.add(key)
|
||||
pending = pending.filter((entry) => announcementKey(entry) !== key)
|
||||
persist()
|
||||
emitState()
|
||||
}
|
||||
function markAllRead() {
|
||||
for (const item of items) {
|
||||
read.add(announcementKey(item))
|
||||
}
|
||||
pending = []
|
||||
persist()
|
||||
emitState()
|
||||
}
|
||||
function advance() {
|
||||
if (disposed || !props.ready || hasModal.value || active.value) return
|
||||
const next = pending.shift()
|
||||
if (next) {
|
||||
void show(next)
|
||||
return
|
||||
}
|
||||
for (const item of items) {
|
||||
const key = announcementKey(item)
|
||||
if (
|
||||
item.type === 'notification' &&
|
||||
!startupNotified.has(key) &&
|
||||
!read.has(key) &&
|
||||
isAnnouncementActive(item)
|
||||
) {
|
||||
startupNotice.value = item
|
||||
startupNotified.add(key)
|
||||
setTimeout(() => {
|
||||
if (startupNotice.value === item) {
|
||||
startupNotice.value = null
|
||||
advance()
|
||||
}
|
||||
}, 8000)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
function closed() {
|
||||
active.value = false
|
||||
centerOpen.value = false
|
||||
if (advanceTimer) clearTimeout(advanceTimer)
|
||||
advanceTimer = setTimeout(advance, 350)
|
||||
}
|
||||
function sync(next: RemoteAnnouncement[], fresh: boolean) {
|
||||
items = next.filter((item) => isAnnouncementActive(item))
|
||||
const keys = new Set(items.map(announcementKey))
|
||||
pending = pending.filter((item) => keys.has(announcementKey(item)))
|
||||
if (selected.value && !keys.has(announcementKey(selected.value))) selected.value = null
|
||||
emitState()
|
||||
if (fresh) {
|
||||
for (const item of items) {
|
||||
const key = announcementKey(item)
|
||||
if (
|
||||
item.type === 'modal' &&
|
||||
!queuedThisSession.has(key) &&
|
||||
(!read.has(key) || item.priority === 'critical')
|
||||
) {
|
||||
queuedThisSession.add(key)
|
||||
pending.push(item)
|
||||
}
|
||||
}
|
||||
advance()
|
||||
}
|
||||
}
|
||||
function loadCache() {
|
||||
if (cacheLoaded || !cacheKey) return
|
||||
cacheLoaded = true
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(cacheKey) ?? 'null')
|
||||
if (cached && typeof cached.savedAt === 'number' && Date.now() - cached.savedAt < 86400000) {
|
||||
const parsed = parseAnnouncements(cached.items)
|
||||
if (parsed) sync(parsed, false)
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed or expired cache payloads
|
||||
}
|
||||
}
|
||||
function saveCache(next: RemoteAnnouncement[]) {
|
||||
if (!cacheKey) return
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), items: next }))
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode / quota)
|
||||
}
|
||||
}
|
||||
const imageUrls = reactive<Record<string, string>>({})
|
||||
|
||||
async function loadImage(item: RemoteAnnouncement, token: string) {
|
||||
const name = item.image_name
|
||||
if (!name || imageUrls[item.id]) return
|
||||
try {
|
||||
const url = `https://skin.starlight.cool/starlight/skin/image/BROADCAST_ATTACHMENT/${encodeURIComponent(name)}`
|
||||
const response = await tauriFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) return
|
||||
const type = response.headers.get('content-type') ?? ''
|
||||
if (type.includes('application/json') || type.includes('text/')) return
|
||||
const blob = await response.blob()
|
||||
imageUrls[item.id] = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
// 图片加载失败不影响公告本身展示
|
||||
}
|
||||
}
|
||||
|
||||
function revokeImages() {
|
||||
for (const url of Object.values(imageUrls)) URL.revokeObjectURL(url)
|
||||
for (const key of Object.keys(imageUrls)) delete imageUrls[key]
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (disposed) return
|
||||
loadCache()
|
||||
if (skinSiteStatus.value !== 'signed-in') return
|
||||
try {
|
||||
await waitForSkinSiteSession()
|
||||
const token = await requestSkinSiteDownloadToken()
|
||||
const response = await tauriFetch('https://skin.starlight.cool/starlight/skin/broadcast/all', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) return
|
||||
const data = await response.json()
|
||||
const parsed = mapBroadcastAnnouncements((data as { payload?: unknown } | null)?.payload)
|
||||
if (parsed && !disposed) {
|
||||
sync(parsed, true)
|
||||
saveCache(parsed)
|
||||
for (const item of parsed) void loadImage(item, token)
|
||||
}
|
||||
} catch {
|
||||
// Remote fetch failed; previously cached items remain displayed
|
||||
}
|
||||
}
|
||||
async function openLink(value: unknown) {
|
||||
const url = safeAnnouncementUrl(value)
|
||||
if (!url) return
|
||||
try {
|
||||
await openUrl(url)
|
||||
} catch {
|
||||
// Opener may reject unknown schemes
|
||||
}
|
||||
}
|
||||
function contentClick(event: MouseEvent) {
|
||||
const link = event.target instanceof Element ? event.target.closest('a') : null
|
||||
if (!link) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void openLink(link.getAttribute('href'))
|
||||
}
|
||||
function preview(type: RemoteAnnouncement['type'], withAction = false) {
|
||||
if (!props.previewOnly || !props.ready || hasModal.value || disposed) return
|
||||
const now = new Date().toISOString()
|
||||
const item: RemoteAnnouncement = {
|
||||
id: 'local-preview-' + type,
|
||||
title: formatMessage(messages.previewTitle),
|
||||
summary: formatMessage(messages.previewSummary),
|
||||
content: formatMessage(messages.previewContent),
|
||||
type,
|
||||
priority: 'normal',
|
||||
starts_at: now,
|
||||
ends_at: null,
|
||||
published_at: now,
|
||||
action_label: withAction ? formatMessage(messages.previewAction) : null,
|
||||
action_url: withAction ? 'https://skin.starlight.cool/' : null,
|
||||
}
|
||||
read.clear()
|
||||
queuedThisSession.clear()
|
||||
sync([item], true)
|
||||
}
|
||||
defineExpose({ preview })
|
||||
onMounted(() => {
|
||||
if (props.previewOnly) return
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(stateKey) ?? 'null')
|
||||
if (saved && Array.isArray(saved.read))
|
||||
for (const key of saved.read) if (typeof key === 'string') read.add(key)
|
||||
} catch {
|
||||
// Ignore malformed local read-state
|
||||
}
|
||||
window.addEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
|
||||
const start = () => {
|
||||
if (disposed) return
|
||||
void refresh()
|
||||
interval = setInterval(() => {
|
||||
sync(items, false)
|
||||
advance()
|
||||
}, 15000)
|
||||
}
|
||||
// Defer the startup request until after first paint.
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(start, { timeout: 2000 })
|
||||
} else {
|
||||
setTimeout(start, 500)
|
||||
}
|
||||
})
|
||||
watch(skinSiteStatus, (status) => {
|
||||
if (disposed || props.previewOnly) return
|
||||
if (status === 'signed-in') void refresh()
|
||||
})
|
||||
watch([() => props.ready, hasModal], () => {
|
||||
if (advanceTimer) clearTimeout(advanceTimer)
|
||||
advanceTimer = setTimeout(advance, 350)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
if (interval) clearInterval(interval)
|
||||
if (advanceTimer) clearTimeout(advanceTimer)
|
||||
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
|
||||
revokeImages()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="startupNotice"
|
||||
class="fixed right-4 top-16 z-50 w-[22rem] max-w-[calc(100vw-2rem)] rounded-xl border border-surface-5 bg-surface-3 p-3 shadow-lg"
|
||||
>
|
||||
<button class="w-full text-left" @click="startupNotice && openCenter(startupNotice)">
|
||||
<div class="mb-1 text-xs font-semibold uppercase text-brand">
|
||||
{{ formatMessage(messages.unread) }}
|
||||
</div>
|
||||
<div class="font-semibold text-[var(--color-text-primary)]">{{ startupNotice.title }}</div>
|
||||
<div
|
||||
v-if="startupNotice.summary"
|
||||
class="mt-1 line-clamp-2 text-sm text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ startupNotice.summary }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.centerTitle)"
|
||||
:on-hide="closed"
|
||||
max-width="80vw"
|
||||
width="80vw"
|
||||
max-content-height="calc(80vh - 8rem)"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex h-[calc(80vh-8rem)] min-h-0 gap-3">
|
||||
<aside class="flex min-h-0 w-[32%] min-w-[14rem] flex-col border-r border-surface-5 pr-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-semibold text-[var(--color-text-primary)]">{{
|
||||
formatMessage(messages.centerTitle)
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="items.some((item) => !read.has(announcementKey(item)))"
|
||||
@click="markAllRead"
|
||||
>{{ formatMessage(messages.readAll) }}</Button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="!items.length"
|
||||
class="py-8 text-center text-sm text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</div>
|
||||
<div v-else class="flex min-h-0 flex-col gap-1 overflow-auto">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="announcementKey(item)"
|
||||
class="flex items-start gap-2 rounded-lg p-2 text-left hover:bg-surface-4"
|
||||
:class="{
|
||||
'bg-surface-4': selected && announcementKey(selected) === announcementKey(item),
|
||||
}"
|
||||
@click="selectAnnouncement(item)"
|
||||
>
|
||||
<span
|
||||
class="mt-1.5 size-2 shrink-0 rounded-full"
|
||||
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
|
||||
/>
|
||||
<img
|
||||
v-if="imageUrls[item.id]"
|
||||
:src="imageUrls[item.id]"
|
||||
alt=""
|
||||
class="size-10 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<span class="block line-clamp-2 text-xs text-[var(--color-text-tertiary)]">{{
|
||||
item.summary || item.content
|
||||
}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section v-if="selected" class="min-h-0 min-w-0 flex-1 overflow-auto px-1">
|
||||
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
|
||||
{{ selected.title }}
|
||||
</h2>
|
||||
<img
|
||||
v-if="imageUrls[selected.id]"
|
||||
:src="imageUrls[selected.id]"
|
||||
alt=""
|
||||
class="mb-4 max-h-80 w-full rounded-lg object-contain"
|
||||
/>
|
||||
<div
|
||||
class="markdown-body break-words"
|
||||
@click="contentClick"
|
||||
@auxclick="contentClick"
|
||||
v-html="html"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button
|
||||
v-if="selected.action_url && selected.action_label"
|
||||
type="colored"
|
||||
color="brand"
|
||||
@click="openLink(selected.action_url)"
|
||||
>{{ selected.action_label }}</Button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }} </Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
frameTitle: {
|
||||
id: 'app.starlight-skin.frame-title',
|
||||
defaultMessage: 'StarLight Skin Site',
|
||||
},
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<iframe
|
||||
:src="skinSiteFrameUrl"
|
||||
:title="formatMessage(messages.frameTitle)"
|
||||
class="block h-full min-h-0 w-full border-0"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,65 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
receiveSkinSiteMessage,
|
||||
resetSkinSiteSession,
|
||||
setSkinSiteFrame,
|
||||
SKIN_SITE_ORIGIN,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
} from '@/composables/skin-site-session'
|
||||
import { clearHostedSession } from '@/helpers/hosted-packs'
|
||||
|
||||
const frame = ref<HTMLIFrameElement>()
|
||||
let lastMessage = 0
|
||||
let expiryTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
watch(
|
||||
[skinSiteStatus, () => skinSiteUser.value?.uuid],
|
||||
() => {
|
||||
void clearHostedSession().catch(() => {})
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function connect() {
|
||||
resetSkinSiteSession()
|
||||
lastMessage = 0
|
||||
const contentWindow = frame.value?.contentWindow ?? null
|
||||
setSkinSiteFrame(contentWindow)
|
||||
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
|
||||
}
|
||||
|
||||
function receive(event: MessageEvent) {
|
||||
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', receive)
|
||||
connect()
|
||||
expiryTimer = setInterval(() => {
|
||||
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
|
||||
}, 10_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', receive)
|
||||
clearInterval(expiryTimer)
|
||||
setSkinSiteFrame(null)
|
||||
resetSkinSiteSession()
|
||||
void clearHostedSession().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<iframe
|
||||
ref="frame"
|
||||
:src="`${SKIN_SITE_ORIGIN}/`"
|
||||
title="StarLight Skin Site session"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
class="pointer-events-none fixed -left-[10000px] top-0 h-px w-px opacity-0"
|
||||
@load="connect"
|
||||
/>
|
||||
</template>
|
||||
@@ -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
|
||||
:announcement="currentAnnouncement"
|
||||
:version="currentVersion"
|
||||
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.website"
|
||||
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.releasesUrl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ const historyAnnouncements = computed(() =>
|
||||
<UpdateAnnouncementContent
|
||||
:announcement="announcement"
|
||||
:show-header="false"
|
||||
:external-url="announcement.externalUrl"
|
||||
:external-url="announcement.externalUrl ?? `${AxolotlBrandConfig.releasesUrl}/tag/v${announcement.version}`"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
@@ -36,24 +36,29 @@ const fullscreenSetting: Ref<boolean> = ref(
|
||||
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
|
||||
)
|
||||
const maximizeWindowSetting = ref(instance.value.maximize_window ?? globalSettings.maximize_window)
|
||||
const windowTitle = ref(instance.value.window_title ?? '')
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
const title = windowTitle.value.trim() ? windowTitle.value.trim() : null
|
||||
if (!overrideWindowSettings.value) {
|
||||
return {
|
||||
force_fullscreen: null,
|
||||
maximize_window: null,
|
||||
game_resolution: null,
|
||||
// The title is independent of the window-size override checkbox.
|
||||
window_title: title,
|
||||
}
|
||||
}
|
||||
return {
|
||||
force_fullscreen: fullscreenSetting.value,
|
||||
maximize_window: maximizeWindowSetting.value,
|
||||
game_resolution: fullscreenSetting.value ? null : resolution.value,
|
||||
window_title: title,
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting],
|
||||
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting, windowTitle],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
@@ -109,6 +114,18 @@ const messages = defineMessages({
|
||||
id: 'instance.settings.tabs.window.height.enter',
|
||||
defaultMessage: 'Enter height...',
|
||||
},
|
||||
windowTitle: {
|
||||
id: 'instance.settings.tabs.window.window-title',
|
||||
defaultMessage: 'Window title',
|
||||
},
|
||||
windowTitleDescription: {
|
||||
id: 'instance.settings.tabs.window.window-title.description',
|
||||
defaultMessage: 'Customize the Minecraft window title. Leave empty to keep the default.',
|
||||
},
|
||||
enterWindowTitle: {
|
||||
id: 'instance.settings.tabs.window.window-title.enter',
|
||||
defaultMessage: 'Enter window title...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -200,5 +217,25 @@ const messages = defineMessages({
|
||||
:placeholder="formatMessage(messages.enterHeight)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="globalSettings.custom_window_title_enabled"
|
||||
class="flex items-center gap-4 justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 inline-flex items-center gap-2 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.windowTitle) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.windowTitleDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="window-title"
|
||||
v-model="windowTitle"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.enterWindowTitle)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
instanceGroupId: string
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'contextmenu' | 'card-click', event: MouseEvent): void
|
||||
(e: 'start-long-press' | 'cancel-long-press'): void
|
||||
}>()
|
||||
|
||||
const cardElement = ref<HTMLElement>()
|
||||
|
||||
const instanceCardSensors = [
|
||||
PointerSensor.configure({
|
||||
preventActivation: () => false,
|
||||
}),
|
||||
KeyboardSensor,
|
||||
]
|
||||
|
||||
const dragDisabled = computed(() => props.disabled)
|
||||
|
||||
const { isDragging } = useDraggable({
|
||||
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
|
||||
element: cardElement,
|
||||
disabled: dragDisabled,
|
||||
sensors: instanceCardSensors,
|
||||
data: computed(() => ({
|
||||
instanceId: props.instance.id,
|
||||
fromGroup: props.instanceGroupId,
|
||||
})),
|
||||
})
|
||||
|
||||
let wantsClick = false
|
||||
|
||||
useEventListener(window, 'pointerdown', () => {
|
||||
wantsClick = true
|
||||
})
|
||||
useEventListener(window, 'pointerup', () => {
|
||||
if (isDragging.value) {
|
||||
wantsClick = false
|
||||
}
|
||||
})
|
||||
useEventListener(window, 'blur', () => {
|
||||
wantsClick = false
|
||||
})
|
||||
|
||||
function onCardClick(event: MouseEvent) {
|
||||
if (!wantsClick || isDragging.value) return
|
||||
wantsClick = false
|
||||
emit('card-click', event)
|
||||
}
|
||||
|
||||
function onContextMenu(event: MouseEvent) {
|
||||
emit('contextmenu', event)
|
||||
}
|
||||
|
||||
function onMouseDown() {
|
||||
if (!dragDisabled.value) {
|
||||
emit('start-long-press')
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
emit('cancel-long-press')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="cardElement"
|
||||
class="group relative min-w-0 w-full"
|
||||
@click="onCardClick"
|
||||
@mousedown="onMouseDown"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
@touchstart="!dragDisabled && onMouseDown()"
|
||||
@touchend="onMouseUp"
|
||||
@touchcancel="onMouseUp"
|
||||
@contextmenu.prevent.stop="onContextMenu"
|
||||
>
|
||||
<slot :is-dragging="isDragging" />
|
||||
<slot v-if="!isDragging" name="overlay" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,521 @@
|
||||
<script setup lang="ts">
|
||||
import { useDroppable } from '@dnd-kit/vue'
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
DropdownIcon,
|
||||
EditIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
InlineEditableText,
|
||||
NewModal,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
import DraggableInstanceCard from '@/components/ui/library/DraggableInstanceCard.vue'
|
||||
import { UNGROUPED_GROUP_KEY } from '@/composables/useGridGrouping'
|
||||
import { FAVORITES_GROUP_ID } from '@/composables/useInstanceGroups'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH = 256
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
ungrouped: { id: 'app.instances.group.ungrouped', defaultMessage: 'No group' },
|
||||
pinned: { id: 'app.instances.group.pinned', defaultMessage: 'Pinned' },
|
||||
emptyGroup: {
|
||||
id: 'app.instances.group.empty',
|
||||
defaultMessage: 'Drag and drop to add instances.',
|
||||
},
|
||||
editGroupName: {
|
||||
id: 'app.instances.group.edit-name',
|
||||
defaultMessage: 'Edit group name',
|
||||
},
|
||||
deleteGroup: {
|
||||
id: 'app.instances.group.delete-group',
|
||||
defaultMessage: 'Delete group',
|
||||
},
|
||||
deleteGroupDescription: {
|
||||
id: 'app.instances.group.delete-description',
|
||||
defaultMessage: 'Instances in this group will be ungrouped.',
|
||||
},
|
||||
moveGroupUp: {
|
||||
id: 'app.instances.group.move-up',
|
||||
defaultMessage: 'Move group up',
|
||||
},
|
||||
moveGroupDown: {
|
||||
id: 'app.instances.group.move-down',
|
||||
defaultMessage: 'Move group down',
|
||||
},
|
||||
addToGroup: {
|
||||
id: 'app.instances.group.add-to-group',
|
||||
defaultMessage: 'Add to group',
|
||||
},
|
||||
newInstance: {
|
||||
id: 'app.instances.group.new-instance',
|
||||
defaultMessage: 'New instance',
|
||||
},
|
||||
collapseGroup: {
|
||||
id: 'app.instances.group.collapse',
|
||||
defaultMessage: 'Collapse group',
|
||||
},
|
||||
expandGroup: {
|
||||
id: 'app.instances.group.expand',
|
||||
defaultMessage: 'Expand group',
|
||||
},
|
||||
renameGroup: {
|
||||
id: 'app.instances.group.rename-group',
|
||||
defaultMessage: 'Rename group',
|
||||
},
|
||||
groupNameEmpty: {
|
||||
id: 'app.instances.group.name-empty',
|
||||
defaultMessage: 'Group names cannot be empty.',
|
||||
},
|
||||
groupNameTooLong: {
|
||||
id: 'app.instances.group.name-too-long',
|
||||
defaultMessage: 'Group names cannot be longer than {maxLength} characters.',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
sectionKey: string
|
||||
groupName?: string
|
||||
instances: GameInstance[]
|
||||
grouping: string
|
||||
isCollapsed: boolean
|
||||
displayMode: string
|
||||
selectMode: boolean
|
||||
selectedInstanceIds: Set<string>
|
||||
isSortAscending?: boolean
|
||||
hideHeader?: boolean
|
||||
canMoveUp?: boolean
|
||||
canMoveDown?: boolean
|
||||
groupPendingNameEdit?: string | null
|
||||
}>()
|
||||
|
||||
// Events are grouped only where the payload shapes genuinely match; the
|
||||
// per-event names are documentation, not part of the type.
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleCollapse' | 'deleteGroup' | 'addToGroup' | 'startLongPress', id: string): void
|
||||
(e: 'handleCheckboxClick' | 'handleCardClick', instanceId: string, event: MouseEvent): void
|
||||
(e: 'handleContextMenu', event: MouseEvent, instanceId: string, sectionKey: string): void
|
||||
(e: 'renameGroup', oldKey: string, newKey: string): void
|
||||
(e: 'cancelLongPress' | 'newInstance' | 'renameComplete'): void
|
||||
(e: 'moveGroup', groupKey: string, direction: -1 | 1): void
|
||||
}>()
|
||||
|
||||
const groupOptions = ref<InstanceType<typeof ContextMenu>>()
|
||||
const groupContainerRef = ref<HTMLElement>()
|
||||
const groupNameInput = ref<InstanceType<typeof InlineEditableText>>()
|
||||
const deletingGroup = ref(false)
|
||||
const confirmDeleteGroupModal = ref<InstanceType<typeof NewModal>>()
|
||||
const groupName = ref(props.groupName ?? props.sectionKey)
|
||||
|
||||
const isUngrouped = computed(() => props.sectionKey === UNGROUPED_GROUP_KEY)
|
||||
const isFavorites = computed(() => props.sectionKey === FAVORITES_GROUP_ID)
|
||||
const isCustomGroup = computed(
|
||||
() => props.grouping === 'Group' && !isUngrouped.value && !isFavorites.value,
|
||||
)
|
||||
const isGroupDragActive = computed(() => props.grouping === 'Group')
|
||||
const effectiveCollapsed = computed(() => (props.hideHeader ? false : props.isCollapsed))
|
||||
|
||||
const { isDropTarget } = useDroppable({
|
||||
id: computed(() => `instance-group:${props.sectionKey}`),
|
||||
element: groupContainerRef,
|
||||
disabled: computed(() => props.hideHeader || !isGroupDragActive.value),
|
||||
data: computed(() => ({
|
||||
groupId: props.sectionKey,
|
||||
})),
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.sectionKey, props.groupName],
|
||||
() => {
|
||||
groupName.value = props.groupName ?? props.sectionKey
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.groupPendingNameEdit,
|
||||
async (pendingId) => {
|
||||
if (pendingId !== props.sectionKey) return
|
||||
|
||||
await nextTick()
|
||||
groupContainerRef.value?.scrollIntoView({ block: 'nearest' })
|
||||
await groupNameInput.value?.startEditing()
|
||||
emit('renameComplete')
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
function validateGroupName(value: string) {
|
||||
const normalized = value.trim()
|
||||
if (normalized.length === 0) return false
|
||||
if (normalized.length > MAX_GROUP_NAME_LENGTH) return false
|
||||
return true
|
||||
}
|
||||
|
||||
async function updateGroupName(value: string) {
|
||||
const newName = value.trim()
|
||||
if (!newName || newName === groupName.value) return false
|
||||
emit('renameGroup', props.sectionKey, newName)
|
||||
return true
|
||||
}
|
||||
|
||||
function openGroupContextMenu(event: MouseEvent) {
|
||||
groupOptions.value?.showMenu(
|
||||
event,
|
||||
props.sectionKey,
|
||||
isCustomGroup.value
|
||||
? [
|
||||
{ name: 'new_instance' },
|
||||
{ name: 'add_instances' },
|
||||
{ name: 'edit_name' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'delete_group', color: 'danger' },
|
||||
]
|
||||
: [{ name: 'new_instance' }],
|
||||
)
|
||||
}
|
||||
|
||||
async function handleGroupOption({ option }: { option: string }) {
|
||||
if (option === 'new_instance') {
|
||||
emit('newInstance')
|
||||
} else if (option === 'add_instances') {
|
||||
emit('addToGroup', props.sectionKey)
|
||||
} else if (option === 'edit_name') {
|
||||
groupNameInput.value?.startEditing()
|
||||
} else if (option === 'delete_group') {
|
||||
requestGroupDeletion()
|
||||
}
|
||||
}
|
||||
|
||||
function requestGroupDeletion() {
|
||||
if (isUngrouped.value || isFavorites.value) return
|
||||
|
||||
if (props.instances.length > 0) {
|
||||
confirmDeleteGroupModal.value?.show()
|
||||
} else {
|
||||
void removeGroup()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGroup() {
|
||||
if (deletingGroup.value) return
|
||||
|
||||
deletingGroup.value = true
|
||||
emit('deleteGroup', props.sectionKey)
|
||||
setTimeout(() => {
|
||||
deletingGroup.value = false
|
||||
confirmDeleteGroupModal.value?.hide()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onCardClick(instanceId: string, event: MouseEvent) {
|
||||
emit('handleCardClick', instanceId, event)
|
||||
}
|
||||
|
||||
function onStartLongPress(instanceId: string) {
|
||||
emit('startLongPress', instanceId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="groupContainerRef"
|
||||
class="instance-group group/instance-container relative select-none pb-3"
|
||||
@contextmenu.prevent.stop="openGroupContextMenu"
|
||||
>
|
||||
<div
|
||||
v-if="!hideHeader && isDropTarget && isGroupDragActive"
|
||||
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-contrast opacity-40 bg-transparent transition-opacity duration-150"
|
||||
/>
|
||||
<div
|
||||
v-if="!hideHeader && (grouping === 'Group' || sectionKey !== UNGROUPED_GROUP_KEY)"
|
||||
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||
:class="{ 'group-drag-handle cursor-grab': isCustomGroup }"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 cursor-pointer items-center gap-2"
|
||||
@click="emit('toggleCollapse', sectionKey)"
|
||||
>
|
||||
<button
|
||||
class="flex shrink-0 items-center border-0 bg-transparent p-0 cursor-pointer"
|
||||
type="button"
|
||||
:aria-expanded="!effectiveCollapsed"
|
||||
:aria-label="
|
||||
formatMessage(effectiveCollapsed ? messages.expandGroup : messages.collapseGroup)
|
||||
"
|
||||
@click.stop="emit('toggleCollapse', sectionKey)"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-300"
|
||||
:class="{ 'rotate-180': !effectiveCollapsed }"
|
||||
/>
|
||||
</button>
|
||||
<InlineEditableText
|
||||
v-if="!isUngrouped && !isFavorites"
|
||||
ref="groupNameInput"
|
||||
v-model="groupName"
|
||||
activation-mode="manual"
|
||||
class="text-base font-semibold !h-10 text-[var(--color-text-default)] select-none"
|
||||
max-width="24rem"
|
||||
:max-length="MAX_GROUP_NAME_LENGTH"
|
||||
:on-change="updateGroupName"
|
||||
:validate="validateGroupName"
|
||||
/>
|
||||
<span v-else class="text-base font-semibold text-[var(--color-text-default)] select-none">
|
||||
{{ isFavorites ? formatMessage(messages.pinned) : formatMessage(messages.ungrouped) }}
|
||||
</span>
|
||||
<TagItem v-if="instances.length" class="shrink-0 border-surface-3 bg-surface-2">
|
||||
{{ instances.length }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<div
|
||||
v-if="isCustomGroup"
|
||||
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.moveGroupUp)"
|
||||
:label="formatMessage(messages.moveGroupUp)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:disabled="!canMoveUp"
|
||||
@click.stop="emit('moveGroup', sectionKey, -1)"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.moveGroupDown)"
|
||||
:label="formatMessage(messages.moveGroupDown)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:disabled="!canMoveDown"
|
||||
@click.stop="emit('moveGroup', sectionKey, 1)"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.editGroupName)"
|
||||
:label="formatMessage(messages.editGroupName)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="groupNameInput?.startEditing()"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.addToGroup)"
|
||||
:label="formatMessage(messages.addToGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="emit('addToGroup', sectionKey)"
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.deleteGroup)"
|
||||
:label="formatMessage(messages.deleteGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:disabled="deletingGroup"
|
||||
@click.stop="handleGroupOption({ option: 'delete_group' })"
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!effectiveCollapsed" class="mt-2.5">
|
||||
<section
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] w-full gap-3 mr-auto scroll-smooth overflow-y-auto"
|
||||
:class="{
|
||||
'grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-4': displayMode === 'cards',
|
||||
}"
|
||||
>
|
||||
<template v-for="instance in instances" :key="instance.id">
|
||||
<DraggableInstanceCard
|
||||
v-if="isGroupDragActive"
|
||||
:instance="instance"
|
||||
:instance-group-id="sectionKey"
|
||||
:disabled="selectMode"
|
||||
@card-click="(event: MouseEvent) => onCardClick(instance.id, event)"
|
||||
@contextmenu="
|
||||
(event: MouseEvent) => emit('handleContextMenu', event, instance.id, props.sectionKey)
|
||||
"
|
||||
@start-long-press="onStartLongPress(instance.id)"
|
||||
@cancel-long-press="emit('cancelLongPress')"
|
||||
>
|
||||
<template #default="{ isDragging }">
|
||||
<div
|
||||
:class="{
|
||||
'pointer-events-none': selectMode,
|
||||
'relative cursor-pointer select-none rounded-lg transition-all hover:brightness-90 active:scale-[0.98]': true,
|
||||
}"
|
||||
@click.capture="
|
||||
(event: MouseEvent) => {
|
||||
if (event.shiftKey && !selectMode) {
|
||||
event.stopPropagation()
|
||||
emit('handleCardClick', instance.id, event)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<Instance
|
||||
:instance="instance"
|
||||
:disabled="selectMode || isDragging"
|
||||
:variant="displayMode === 'cards' ? 'library' : 'standard'"
|
||||
:class="{
|
||||
'opacity-50': selectMode && !selectedInstanceIds.has(instance.id),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #overlay>
|
||||
<div
|
||||
class="absolute right-2 bottom-2 z-10 transition-opacity"
|
||||
:class="
|
||||
selectMode && selectedInstanceIds.has(instance.id)
|
||||
? ''
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
"
|
||||
@click.stop="emit('handleCheckboxClick', instance.id, $event)"
|
||||
>
|
||||
<Checkbox :model-value="selectedInstanceIds.has(instance.id)" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectMode"
|
||||
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click.stop="
|
||||
emit('handleContextMenu', $event as MouseEvent, instance.id, props.sectionKey)
|
||||
"
|
||||
>
|
||||
<Button type="quiet" size="2xs" circular icon-only
|
||||
><slot name="moreIcon" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</DraggableInstanceCard>
|
||||
|
||||
<div v-else class="group relative">
|
||||
<div
|
||||
class="relative cursor-pointer select-none rounded-lg transition-all hover:brightness-90 active:scale-[0.98]"
|
||||
@click.capture="
|
||||
(event: MouseEvent) => {
|
||||
if (event.shiftKey && !selectMode) {
|
||||
event.stopPropagation()
|
||||
emit('handleCardClick', instance.id, event)
|
||||
}
|
||||
}
|
||||
"
|
||||
@click="emit('handleCardClick', instance.id, $event as MouseEvent)"
|
||||
@mousedown="!selectMode && emit('startLongPress', instance.id)"
|
||||
@mouseup="emit('cancelLongPress')"
|
||||
@mouseleave="emit('cancelLongPress')"
|
||||
@touchstart="!selectMode && emit('startLongPress', instance.id)"
|
||||
@touchend="emit('cancelLongPress')"
|
||||
@touchcancel="emit('cancelLongPress')"
|
||||
>
|
||||
<div :class="{ 'pointer-events-none': selectMode }">
|
||||
<Instance
|
||||
:instance="instance"
|
||||
:disabled="selectMode"
|
||||
:variant="displayMode === 'cards' ? 'library' : 'standard'"
|
||||
:class="{
|
||||
'opacity-50': selectMode && !selectedInstanceIds.has(instance.id),
|
||||
}"
|
||||
@contextmenu.prevent.stop="
|
||||
(event: MouseEvent) =>
|
||||
emit('handleContextMenu', event, instance.id, props.sectionKey)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute right-2 bottom-2 z-10 transition-opacity"
|
||||
:class="
|
||||
selectMode && selectedInstanceIds.has(instance.id)
|
||||
? ''
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
"
|
||||
@click.stop="emit('handleCheckboxClick', instance.id, $event)"
|
||||
>
|
||||
<Checkbox :model-value="selectedInstanceIds.has(instance.id)" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectMode"
|
||||
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click.stop="
|
||||
emit('handleContextMenu', $event as MouseEvent, instance.id, props.sectionKey)
|
||||
"
|
||||
>
|
||||
<Button type="quiet" size="2xs" circular icon-only><slot name="moreIcon" /> </Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p
|
||||
v-if="instances.length === 0"
|
||||
class="col-span-full m-0 pt-1 pl-0.5 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContextMenu ref="groupOptions" @option-clicked="handleGroupOption">
|
||||
<template #new_instance>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.newInstance) }}
|
||||
</template>
|
||||
<template #add_instances>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addToGroup) }}
|
||||
</template>
|
||||
<template #edit_name>
|
||||
<EditIcon />
|
||||
{{ formatMessage(messages.editGroupName) }}
|
||||
</template>
|
||||
<template #delete_group>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteGroup) }}
|
||||
</template>
|
||||
</ContextMenu>
|
||||
|
||||
<NewModal
|
||||
ref="confirmDeleteGroupModal"
|
||||
:header="formatMessage(messages.deleteGroup)"
|
||||
fade="danger"
|
||||
width="500px"
|
||||
>
|
||||
<p class="m-0 text-base text-[var(--color-text-default)]">
|
||||
{{ formatMessage(messages.deleteGroupDescription) }}
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="outlined" @click="confirmDeleteGroupModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="red" :disabled="deletingGroup" @click="removeGroup">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteGroup) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface CrashLogFile {
|
||||
age: number
|
||||
filename: string
|
||||
log_type: 'InfoLog' | 'CrashReport' | 'JvmCrash' | 'LauncherLog'
|
||||
output?: string | null
|
||||
}
|
||||
|
||||
const RUN_ASSOCIATION_WINDOW_SECONDS = 3 * 60
|
||||
|
||||
export function shouldUseLogShareAutoAnalysis(settings: {
|
||||
ai_source: 'logshare' | 'custom'
|
||||
auto_upload: boolean
|
||||
no_storage?: boolean
|
||||
}): boolean {
|
||||
return settings.ai_source === 'logshare' && settings.auto_upload
|
||||
}
|
||||
|
||||
function isRelevantLog(file: CrashLogFile): boolean {
|
||||
const lower = file.filename.toLowerCase()
|
||||
if (file.log_type === 'CrashReport') return lower.endsWith('.txt')
|
||||
if (file.log_type === 'JvmCrash') return lower.startsWith('hs_err') && lower.endsWith('.log')
|
||||
return ['latest.log', 'debug.log', 'launcher_log.txt', 'latest_stdout.log'].includes(lower)
|
||||
}
|
||||
|
||||
export function selectCrashLogFiles(files: CrashLogFile[]): CrashLogFile[] {
|
||||
const sorted = files.filter(isRelevantLog).sort((left, right) => right.age - left.age)
|
||||
const anchor = sorted[0]?.age
|
||||
if (anchor === undefined) return []
|
||||
|
||||
const singletonTypes = new Set<CrashLogFile['log_type']>()
|
||||
return sorted.filter((file) => {
|
||||
if (anchor - file.age > RUN_ASSOCIATION_WINDOW_SECONDS) return false
|
||||
if (file.log_type !== 'CrashReport' && file.log_type !== 'JvmCrash') return true
|
||||
if (singletonTypes.has(file.log_type)) return false
|
||||
singletonTypes.add(file.log_type)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function crashLogKey(file: CrashLogFile): string {
|
||||
return `${file.log_type}:${file.filename}`
|
||||
}
|
||||
|
||||
export function crashLogLabel(file: CrashLogFile): string {
|
||||
return file.log_type === 'CrashReport' ? `crash-reports/${file.filename}` : file.filename
|
||||
}
|
||||
|
||||
export function preferredCrashLogKey(files: CrashLogFile[]): string {
|
||||
const preferred =
|
||||
files.find((file) => file.log_type === 'CrashReport') ??
|
||||
files.find((file) => file.log_type === 'JvmCrash') ??
|
||||
files.find((file) => file.filename.toLowerCase() === 'latest.log') ??
|
||||
files[0]
|
||||
return preferred ? crashLogKey(preferred) : ''
|
||||
}
|
||||
|
||||
export function combineCrashLogs(files: CrashLogFile[]): string {
|
||||
return files
|
||||
.filter((file) => file.output)
|
||||
.map((file) => `===== ${crashLogLabel(file)} =====\n${file.output}`)
|
||||
.join('\n\n')
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
RadioButtons,
|
||||
StyledInput,
|
||||
@@ -72,6 +73,7 @@ import { edit, list } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const props = defineProps<{
|
||||
instanceIds: string[]
|
||||
@@ -148,7 +150,7 @@ async function deleteGroup(group: string) {
|
||||
for (const instance of allInstances.value) {
|
||||
if (instance.groups.includes(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
|
||||
}
|
||||
}
|
||||
@@ -167,7 +169,7 @@ async function confirm() {
|
||||
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
|
||||
|
||||
for (const instanceId of props.instanceIds) {
|
||||
await edit(instanceId, { groups }).catch(() => {})
|
||||
await edit(instanceId, { groups }).catch(handleError)
|
||||
}
|
||||
|
||||
emit('applied')
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<NewModal ref="modal" no-padding scrollable actions-divider max-width="560px" width="560px">
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-[var(--color-text-primary)]">
|
||||
{{
|
||||
existingGroupName
|
||||
? formatMessage(messages.addToGroupTitle, { groupName: existingGroupName })
|
||||
: formatMessage(messages.header)
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-if="!existingGroupName">
|
||||
<div class="flex flex-col gap-2.5 p-6">
|
||||
<label for="new-group-name" class="font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.groupName) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
id="new-group-name"
|
||||
ref="groupNameInput"
|
||||
v-model="newGroupName"
|
||||
:placeholder="formatMessage(messages.groupNamePlaceholder)"
|
||||
:maxlength="32"
|
||||
@click="groupNameInput?.select()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-divider" />
|
||||
</template>
|
||||
|
||||
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
|
||||
<div class="px-6">
|
||||
<StyledInput
|
||||
v-model="newGroupSearch"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchInstance)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newGroupInstances.length === 0"
|
||||
class="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.noInstancesFound) }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="instance in newGroupInstances"
|
||||
:key="instance.id"
|
||||
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
|
||||
:class="{ 'opacity-60': selectedNewGroupInstanceIds.has(instance.id) }"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
class="!size-[2rem] !rounded-md"
|
||||
/>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
|
||||
instance.name
|
||||
}}</span>
|
||||
<TagItem v-if="instance.groups && instance.groups.length > 0" class="shrink-0">
|
||||
{{ getGroupName(instance.groups[0]) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:type="selectedNewGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
|
||||
@click="toggleNewGroupInstance(instance.id)"
|
||||
>
|
||||
<CheckIcon v-if="selectedNewGroupInstanceIds.has(instance.id)" />
|
||||
{{
|
||||
formatMessage(
|
||||
selectedNewGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button type="outlined" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="existingGroupName ? !canSaveGroups : !canCreateGroup"
|
||||
@click="existingGroupName ? handleSaveGroups() : handleCreateGroup()"
|
||||
>
|
||||
<SpinnerIcon v-if="creatingGroup" class="animate-spin" />
|
||||
<CheckIcon v-else-if="existingGroupName" />
|
||||
<PlusIcon v-else />
|
||||
{{ formatMessage(existingGroupName ? messages.saveChanges : messages.createGroup) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, NewModal, StyledInput, TagItem, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { FAVORITES_GROUP_ID } from '@/composables/useInstanceGroups'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { create_group, list_groups, set_group_memberships } from '@/helpers/instance-groups'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
instanceIds: string[]
|
||||
existingGroupName?: string
|
||||
existingGroupId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'applied'): void
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instances.batch-edit-groups.header',
|
||||
defaultMessage: 'Create group',
|
||||
},
|
||||
addToGroupTitle: {
|
||||
id: 'app.instances.batch-edit-groups.add-to-group-title',
|
||||
defaultMessage: 'Add instances to "{groupName}"',
|
||||
},
|
||||
groupName: {
|
||||
id: 'app.instances.batch-edit-groups.group-name',
|
||||
defaultMessage: 'Group name',
|
||||
},
|
||||
groupNamePlaceholder: {
|
||||
id: 'app.instances.batch-edit-groups.group-name-placeholder',
|
||||
defaultMessage: 'Enter group name',
|
||||
},
|
||||
searchInstance: {
|
||||
id: 'app.instances.batch-edit-groups.search-instance',
|
||||
defaultMessage: 'Search instances',
|
||||
},
|
||||
noInstancesFound: {
|
||||
id: 'app.instances.batch-edit-groups.no-instances-found',
|
||||
defaultMessage: 'No instances found',
|
||||
},
|
||||
add: {
|
||||
id: 'app.instances.batch-edit-groups.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
added: {
|
||||
id: 'app.instances.batch-edit-groups.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
cancel: {
|
||||
id: 'app.instances.batch-edit-groups.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
createGroup: {
|
||||
id: 'app.instances.batch-edit-groups.create',
|
||||
defaultMessage: 'Create group',
|
||||
},
|
||||
pinnedGroupName: {
|
||||
id: 'app.instances.group.pinned',
|
||||
defaultMessage: 'Pinned',
|
||||
},
|
||||
saveChanges: {
|
||||
id: 'app.instances.batch-edit-groups.save-changes',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
|
||||
const newGroupName = ref('')
|
||||
const newGroupSearch = ref('')
|
||||
const creatingGroup = ref(false)
|
||||
const allInstances = ref<GameInstance[]>([])
|
||||
const groupNameMap = ref(new Map<string, string>())
|
||||
const selectedNewGroupInstanceIds = ref(new Set<string>())
|
||||
|
||||
const canCreateGroup = computed(() => newGroupName.value.trim().length > 0)
|
||||
|
||||
const canSaveGroups = computed(() => {
|
||||
if (!props.existingGroupName) return false
|
||||
const groupId = props.existingGroupId
|
||||
if (!groupId) return false
|
||||
return allInstances.value.some((instance) => {
|
||||
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
|
||||
const hasGroup = (instance.groups || []).includes(groupId)
|
||||
return isSelected !== hasGroup
|
||||
})
|
||||
})
|
||||
|
||||
const newGroupInstances = computed(() => {
|
||||
const search = newGroupSearch.value.toLowerCase()
|
||||
return allInstances.value.filter((instance) => {
|
||||
if (!search) return true
|
||||
return instance.name.toLowerCase().includes(search)
|
||||
})
|
||||
})
|
||||
|
||||
function getGroupName(groupId: string) {
|
||||
if (groupId === FAVORITES_GROUP_ID) {
|
||||
return formatMessage(messages.pinnedGroupName)
|
||||
}
|
||||
return groupNameMap.value.get(groupId) || groupId
|
||||
}
|
||||
|
||||
function show(ids?: string[]) {
|
||||
newGroupSearch.value = ''
|
||||
creatingGroup.value = false
|
||||
Promise.all([list(), list_groups()]).then(([instances, groups]) => {
|
||||
allInstances.value = instances as GameInstance[]
|
||||
const map = new Map<string, string>()
|
||||
for (const g of groups) {
|
||||
map.set(g.id, g.name)
|
||||
}
|
||||
groupNameMap.value = map
|
||||
|
||||
if (!props.existingGroupName) {
|
||||
const customGroups = groups.filter((g: { id: string }) => g.id !== 'group:favorites')
|
||||
let groupNumber = customGroups.length + 1
|
||||
const existingNames = new Set(groups.map((g: { name: string }) => g.name.toLowerCase()))
|
||||
while (existingNames.has(`group ${groupNumber}`)) {
|
||||
groupNumber++
|
||||
}
|
||||
newGroupName.value = `Group ${groupNumber}`
|
||||
}
|
||||
|
||||
if (props.existingGroupName) {
|
||||
const groupId = props.existingGroupId
|
||||
selectedNewGroupInstanceIds.value = new Set(
|
||||
allInstances.value.filter((i) => (i.groups || []).includes(groupId || '')).map((i) => i.id),
|
||||
)
|
||||
} else {
|
||||
selectedNewGroupInstanceIds.value = new Set(ids ?? props.instanceIds)
|
||||
}
|
||||
})
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function toggleNewGroupInstance(instanceId: string) {
|
||||
const next = new Set(selectedNewGroupInstanceIds.value)
|
||||
if (next.has(instanceId)) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.add(instanceId)
|
||||
}
|
||||
selectedNewGroupInstanceIds.value = next
|
||||
}
|
||||
|
||||
async function handleCreateGroup() {
|
||||
const name = newGroupName.value.trim()
|
||||
if (!name || selectedNewGroupInstanceIds.value.size === 0) return
|
||||
|
||||
creatingGroup.value = true
|
||||
|
||||
const group = await create_group(name).catch(() => null)
|
||||
if (!group) {
|
||||
creatingGroup.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedNewGroupInstanceIds.value.size > 0) {
|
||||
const updates = [...selectedNewGroupInstanceIds.value].map((instanceId) => ({
|
||||
instance_id: instanceId,
|
||||
group_ids: [group.id],
|
||||
}))
|
||||
await set_group_memberships(updates).catch(() => {})
|
||||
}
|
||||
|
||||
creatingGroup.value = false
|
||||
modal.value?.hide()
|
||||
emit('applied')
|
||||
}
|
||||
|
||||
async function handleSaveGroups() {
|
||||
if (!props.existingGroupName) return
|
||||
|
||||
creatingGroup.value = true
|
||||
const groupId = props.existingGroupId
|
||||
|
||||
const changedInstances = allInstances.value.filter((instance) => {
|
||||
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
|
||||
const hasGroup = (instance.groups || []).includes(groupId || '')
|
||||
return isSelected !== hasGroup
|
||||
})
|
||||
|
||||
const updates = changedInstances.map((instance) => {
|
||||
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
|
||||
return {
|
||||
instance_id: instance.id,
|
||||
group_ids: isSelected ? [groupId!] : [],
|
||||
}
|
||||
})
|
||||
await set_group_memberships(updates).catch(() => {})
|
||||
|
||||
creatingGroup.value = false
|
||||
modal.value?.hide()
|
||||
emit('applied')
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FileArchiveIcon,
|
||||
InfoIcon,
|
||||
MonitorIcon,
|
||||
RefreshCwIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -20,8 +21,10 @@ import type { PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
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 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 InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||
@@ -52,8 +55,9 @@ let serverMetadataGeneration = 0
|
||||
const instanceRef = computed(() => props.instance)
|
||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
function hide() {
|
||||
function hide(onAfterClose?: () => void) {
|
||||
tabbedModal.value?.hide()
|
||||
if (onAfterClose) nextTick(onAfterClose)
|
||||
}
|
||||
|
||||
provideInstanceSettings({
|
||||
@@ -105,6 +109,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
||||
icon: WrenchIcon,
|
||||
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.
|
||||
name: defineMessage({
|
||||
@@ -138,6 +150,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
||||
icon: CodeIcon,
|
||||
content: HooksSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.sync',
|
||||
defaultMessage: 'Sync',
|
||||
}),
|
||||
icon: RefreshCwIcon,
|
||||
content: SyncSettings,
|
||||
},
|
||||
])
|
||||
|
||||
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>
|
||||
</div>
|
||||
<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 />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -170,7 +170,7 @@ const {
|
||||
top: auto !important;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0 !important;
|
||||
left: var(--left-bar-width, 4rem) !important;
|
||||
z-index: 2;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -252,6 +252,42 @@ export const onboardingMessages = defineMessages({
|
||||
id: 'app.onboarding.instance-tabs.description',
|
||||
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: {
|
||||
id: 'app.onboarding.lab.title',
|
||||
defaultMessage: 'Useful tools, built in',
|
||||
@@ -276,6 +312,48 @@ export const onboardingMessages = defineMessages({
|
||||
},
|
||||
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
|
||||
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 = (
|
||||
@@ -331,20 +409,33 @@ const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescri
|
||||
|
||||
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
main: [
|
||||
step(
|
||||
'welcome',
|
||||
'manual',
|
||||
copy(
|
||||
onboardingMessages.welcomeTitle,
|
||||
onboardingMessages.welcomeDescription,
|
||||
onboardingMessages.start,
|
||||
),
|
||||
inspect(
|
||||
'home-intro',
|
||||
'home-widget-grid',
|
||||
onboardingMessages.homeIntroTitle,
|
||||
onboardingMessages.homeIntroDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-layout-switch',
|
||||
'home-layout-switch',
|
||||
onboardingMessages.homeLayoutTitle,
|
||||
onboardingMessages.homeLayoutDescription,
|
||||
'account-login',
|
||||
'sidebar-accounts',
|
||||
onboardingMessages.accountLoginTitle,
|
||||
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(
|
||||
'discover-navigation',
|
||||
@@ -354,7 +445,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.discoverDescription,
|
||||
onboardingMessages.clickDiscover,
|
||||
),
|
||||
control('nav-discover', '/browse/modpack'),
|
||||
control('nav-discover', '/browse'),
|
||||
),
|
||||
inspect(
|
||||
'discover-content',
|
||||
@@ -362,22 +453,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.browseTitle,
|
||||
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(
|
||||
'skins-navigation',
|
||||
'navigate',
|
||||
@@ -394,6 +469,22 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.skinsPageTitle,
|
||||
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(
|
||||
'lab-navigation',
|
||||
'navigate',
|
||||
@@ -428,7 +519,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
),
|
||||
step(
|
||||
'settings-navigation',
|
||||
'activate',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.settingsTitle,
|
||||
onboardingMessages.settingsDescription,
|
||||
@@ -436,95 +527,15 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
),
|
||||
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(
|
||||
'library-navigation',
|
||||
'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',
|
||||
'settings-updates',
|
||||
'activate',
|
||||
copy(
|
||||
onboardingMessages.creationTitle,
|
||||
onboardingMessages.creationDescription,
|
||||
onboardingMessages.clickCreationMethod,
|
||||
onboardingMessages.settingsUpdatesTitle,
|
||||
onboardingMessages.settingsUpdatesDescription,
|
||||
onboardingMessages.clickTab,
|
||||
),
|
||||
{
|
||||
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' },
|
||||
control('settings-tab-updates'),
|
||||
),
|
||||
],
|
||||
instance: [
|
||||
@@ -534,15 +545,29 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.instanceActionsTitle,
|
||||
onboardingMessages.instanceActionsDescription,
|
||||
),
|
||||
step(
|
||||
'instance-tabs',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.instanceTabsTitle,
|
||||
onboardingMessages.instanceTabsDescription,
|
||||
onboardingMessages.finishArea,
|
||||
),
|
||||
{ targetId: 'instance-tabs' },
|
||||
inspect(
|
||||
'instance-content-tab',
|
||||
'instance-tab-content',
|
||||
onboardingMessages.instanceContentTabTitle,
|
||||
onboardingMessages.instanceContentTabDescription,
|
||||
),
|
||||
inspect(
|
||||
'instance-files-tab',
|
||||
'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) {
|
||||
if (targetRetryCount >= missingTargetRetryLimit) {
|
||||
// 超过重试上限:inspect 步(介绍性步骤)不自动跳过,
|
||||
// 保持无锚点状态,让气泡停靠底部等用户手动继续。
|
||||
if (step.value.interaction === 'inspect') {
|
||||
targetElement.value = undefined
|
||||
targetRect.value = null
|
||||
clearModalReservation()
|
||||
return
|
||||
}
|
||||
void advance()
|
||||
return
|
||||
}
|
||||
@@ -189,6 +197,27 @@ export function useOnboardingTour(
|
||||
requestAnimationFrame(updateRect)
|
||||
}
|
||||
|
||||
// Bring the current step target into view when the tour jumps to a step
|
||||
// whose target lives inside a scrollable container (e.g. the settings
|
||||
// sidebar). Without this the bubble can point at an off-screen element.
|
||||
function ensureTargetVisible() {
|
||||
const target = targetElement.value
|
||||
if (!target) return
|
||||
const rect = target.getBoundingClientRect()
|
||||
const outOfView =
|
||||
rect.top < 0 ||
|
||||
rect.left < 0 ||
|
||||
rect.bottom > window.innerHeight ||
|
||||
rect.right > window.innerWidth
|
||||
if (!outOfView) return
|
||||
target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' })
|
||||
requestAnimationFrame(() => {
|
||||
if (targetElement.value === target) {
|
||||
targetRect.value = target.getBoundingClientRect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goTo(destination: StepDestination) {
|
||||
if (destination === 'complete') {
|
||||
events.complete()
|
||||
@@ -271,8 +300,11 @@ export function useOnboardingTour(
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!visible.value) return
|
||||
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') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
@@ -280,11 +312,14 @@ export function useOnboardingTour(
|
||||
return
|
||||
}
|
||||
|
||||
// 非 inspect 步:点在导览 UI 自身时不处理
|
||||
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
|
||||
|
||||
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
|
||||
if (clickedElement && handleBranchClick(clickedElement)) 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
|
||||
return
|
||||
}
|
||||
@@ -301,7 +336,7 @@ export function useOnboardingTour(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (!waitingForRoute.value || !step.value.expectedPath) return
|
||||
if (path !== step.value.expectedPath) return
|
||||
if (!path.startsWith(step.value.expectedPath)) return
|
||||
waitingForRoute.value = false
|
||||
void advance()
|
||||
},
|
||||
@@ -329,6 +364,7 @@ export function useOnboardingTour(
|
||||
await nextTick()
|
||||
updateBubbleSize()
|
||||
updateTarget()
|
||||
ensureTargetVisible()
|
||||
})
|
||||
|
||||
watch(mode, () => {
|
||||
|
||||
@@ -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 { open } from '@tauri-apps/plugin-dialog'
|
||||
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 { getOS } from '@/helpers/utils'
|
||||
import { setUiScale, uiScale } from '@/helpers/ui-scale'
|
||||
import { useTheming } from '@/store/state'
|
||||
import {
|
||||
type AccentColor,
|
||||
@@ -40,6 +41,19 @@ import {
|
||||
parseCustomAccentColor,
|
||||
} 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 SettingsSection from './SettingsSection.vue'
|
||||
|
||||
@@ -209,6 +223,15 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'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: {
|
||||
id: 'app.appearance-settings.page-transitions.title',
|
||||
defaultMessage: 'Page transition animations',
|
||||
@@ -353,6 +376,63 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'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())
|
||||
@@ -363,6 +443,84 @@ const customBackgroundPreview = computed(() =>
|
||||
: 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<{
|
||||
value: AccentColor
|
||||
color: string
|
||||
@@ -473,6 +631,8 @@ watch(
|
||||
settings.value.transparent_background_blur,
|
||||
settings.value.sidebar_instance_count,
|
||||
settings.value.close_behavior,
|
||||
settings.value.ui_font,
|
||||
settings.value.mono_font,
|
||||
] as const,
|
||||
([
|
||||
path,
|
||||
@@ -483,6 +643,8 @@ watch(
|
||||
transparentBlur,
|
||||
sidebarInstanceCount,
|
||||
closeBehavior,
|
||||
uiFont,
|
||||
monoFont,
|
||||
]) => {
|
||||
themeStore.customBackgroundPath = path
|
||||
themeStore.customBackgroundBlur = blur
|
||||
@@ -493,10 +655,19 @@ watch(
|
||||
themeStore.setTransparentBackgroundClass()
|
||||
themeStore.sidebarInstanceCount = sidebarInstanceCount
|
||||
themeStore.closeBehavior = closeBehavior as CloseBehavior
|
||||
themeStore.uiFont = uiFont
|
||||
themeStore.monoFont = monoFont
|
||||
themeStore.setUiFont()
|
||||
themeStore.setMonoFont()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.scope === 'interface') {
|
||||
void loadSystemFonts()
|
||||
}
|
||||
})
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
@@ -1020,6 +1191,43 @@ watch(
|
||||
/>
|
||||
</template>
|
||||
</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>
|
||||
<template #label>
|
||||
<span id="settings-target-appearance-page-transitions" tabindex="-1">
|
||||
@@ -1095,6 +1303,118 @@ watch(
|
||||
</SettingsRow>
|
||||
</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'">
|
||||
<SettingsRow>
|
||||
<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>
|
||||
@@ -6,6 +6,8 @@ import { ref, watch } from 'vue'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
|
||||
import CrashAnalysisAISettings from './CrashAnalysisAISettings.vue'
|
||||
import LogShareSettings from './LogShareSettings.vue'
|
||||
import SharedLogsSettings from './SharedLogsSettings.vue'
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
@@ -38,6 +40,26 @@ const messages = defineMessages({
|
||||
id: 'app.settings.defaults.maximize-window.unsupported',
|
||||
defaultMessage: 'Not supported on this operating system.',
|
||||
},
|
||||
windowTitleEnabled: {
|
||||
id: 'app.settings.defaults.window-title.enabled',
|
||||
defaultMessage: 'Custom Minecraft window title',
|
||||
},
|
||||
windowTitleEnabledDescription: {
|
||||
id: 'app.settings.defaults.window-title.enabled-description',
|
||||
defaultMessage: 'Allow instances to customize the Minecraft window title.',
|
||||
},
|
||||
defaultWindowTitle: {
|
||||
id: 'app.settings.defaults.window-title.default',
|
||||
defaultMessage: 'Default window title',
|
||||
},
|
||||
defaultWindowTitleDescription: {
|
||||
id: 'app.settings.defaults.window-title.default-description',
|
||||
defaultMessage: 'Used when an instance leaves its window title empty.',
|
||||
},
|
||||
defaultWindowTitlePlaceholder: {
|
||||
id: 'app.settings.defaults.window-title.default-placeholder',
|
||||
defaultMessage: 'Enter default window title...',
|
||||
},
|
||||
width: { id: 'app.settings.defaults.width', defaultMessage: 'Width' },
|
||||
widthDescription: {
|
||||
id: 'app.settings.defaults.width-description',
|
||||
@@ -212,6 +234,36 @@ watch(
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>
|
||||
<span id="settings-target-defaults-window-title" tabindex="-1">
|
||||
{{ formatMessage(messages.windowTitleEnabled) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #description>{{
|
||||
formatMessage(messages.windowTitleEnabledDescription)
|
||||
}}</template>
|
||||
<template #control>
|
||||
<Toggle id="custom-window-title-enabled" v-model="settings.custom_window_title_enabled" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow stacked>
|
||||
<template #label>{{ formatMessage(messages.defaultWindowTitle) }}</template>
|
||||
<template #description>{{
|
||||
formatMessage(messages.defaultWindowTitleDescription)
|
||||
}}</template>
|
||||
<template #control>
|
||||
<StyledInput
|
||||
id="default-window-title"
|
||||
v-model="settings.default_window_title"
|
||||
:disabled="!settings.custom_window_title_enabled"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.defaultWindowTitlePlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
@@ -341,5 +393,7 @@ watch(
|
||||
</SettingsSection>
|
||||
|
||||
<CrashAnalysisAISettings />
|
||||
<LogShareSettings />
|
||||
<SharedLogsSettings />
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} 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 { isDev } from '@/helpers/utils'
|
||||
@@ -54,6 +54,26 @@ 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: '自动安装依赖',
|
||||
force_anniversary_celebration: '强制显示周年庆庆祝(调试)',
|
||||
}
|
||||
|
||||
const settings = ref(await getSettings())
|
||||
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
|
||||
|
||||
@@ -85,7 +105,7 @@ watch(
|
||||
<template>
|
||||
<SettingsSection>
|
||||
<SettingsRow v-for="option in options" :key="option">
|
||||
<template #label>{{ option.replaceAll('_', ' ') }}</template>
|
||||
<template #label>{{ featureFlagLabels[option] ?? option }}</template>
|
||||
<template #control>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
defineMessages,
|
||||
type KeyBinding,
|
||||
KeybindingChips,
|
||||
KeybindingModal,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getNavShortcutEnabled, setNavShortcutEnabled } from '@/helpers/nav-shortcut-state'
|
||||
import { getQuickScrollEnabled, setQuickScrollEnabled } from '@/helpers/scroll-top-state'
|
||||
import { NAV_ACTIONS, SCROLL_ACTIONS, type ShortcutAction } from '@/helpers/shortcut-actions'
|
||||
import {
|
||||
buildConflicts,
|
||||
clearStoredBinding,
|
||||
resolveAllBindings,
|
||||
storeBinding,
|
||||
} from '@/helpers/shortcut-bindings'
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
|
||||
themeStore.quickScrollEnabled = getQuickScrollEnabled()
|
||||
themeStore.shortcutBindings = resolveAllBindings()
|
||||
for (const action of NAV_ACTIONS) {
|
||||
themeStore[action.enabledField] = getNavShortcutEnabled(action.id)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
scrollTitle: {
|
||||
id: 'app.shortcut-settings.scroll-title',
|
||||
defaultMessage: 'Quick scrolling',
|
||||
},
|
||||
scrollDescription: {
|
||||
id: 'app.shortcut-settings.scroll-description',
|
||||
defaultMessage: 'Move through long pages with the keyboard.',
|
||||
},
|
||||
enable: { id: 'app.shortcut-settings.enable', defaultMessage: 'Enable quick scrolling' },
|
||||
navTitle: {
|
||||
id: 'app.shortcut-settings.nav-title',
|
||||
defaultMessage: 'Navigation shortcuts',
|
||||
},
|
||||
navDescription: {
|
||||
id: 'app.shortcut-settings.nav-description',
|
||||
defaultMessage: 'Jump to a menu item with its shortcut. Each shortcut is off until enabled.',
|
||||
},
|
||||
changeShortcut: {
|
||||
id: 'app.shortcut-settings.change-shortcut',
|
||||
defaultMessage: 'Change the shortcut for {action}',
|
||||
},
|
||||
})
|
||||
|
||||
function actionLabel(action: ShortcutAction) {
|
||||
return formatMessage(action.label)
|
||||
}
|
||||
|
||||
function actionDescription(action: ShortcutAction) {
|
||||
return formatMessage(action.description)
|
||||
}
|
||||
|
||||
function bindingFor(action: ShortcutAction): KeyBinding {
|
||||
return themeStore.shortcutBindings[action.id] ?? action.defaultBinding
|
||||
}
|
||||
|
||||
function sameBinding(left: KeyBinding, right: KeyBinding) {
|
||||
return (
|
||||
left.device === right.device &&
|
||||
left.code === right.code &&
|
||||
left.mod === right.mod &&
|
||||
left.shift === right.shift &&
|
||||
left.alt === right.alt
|
||||
)
|
||||
}
|
||||
|
||||
const editing = ref<ShortcutAction | null>(null)
|
||||
const keybindingModal = ref<InstanceType<typeof KeybindingModal>>()
|
||||
|
||||
const editingLabel = computed(() => (editing.value ? actionLabel(editing.value) : undefined))
|
||||
const editingBinding = computed(() => (editing.value ? bindingFor(editing.value) : null))
|
||||
const editingDefault = computed(() => editing.value?.defaultBinding ?? null)
|
||||
const editingConflicts = computed(() =>
|
||||
editing.value
|
||||
? buildConflicts(editing.value.id, (id) => {
|
||||
const action = [...SCROLL_ACTIONS, ...NAV_ACTIONS].find((candidate) => candidate.id === id)
|
||||
return action ? actionLabel(action) : id
|
||||
})
|
||||
: [],
|
||||
)
|
||||
|
||||
function openShortcut(action: ShortcutAction) {
|
||||
editing.value = action
|
||||
keybindingModal.value?.show()
|
||||
}
|
||||
|
||||
function saveShortcut(binding: KeyBinding) {
|
||||
const action = editing.value
|
||||
if (!action) return
|
||||
|
||||
// A combination matching the default is stored as "not changed", so a later
|
||||
// change to the default still reaches this shortcut.
|
||||
if (sameBinding(binding, action.defaultBinding)) clearStoredBinding(action.id)
|
||||
else storeBinding(action.id, binding)
|
||||
|
||||
themeStore.shortcutBindings = { ...themeStore.shortcutBindings, [action.id]: binding }
|
||||
}
|
||||
|
||||
function toggleQuickScroll(value: unknown) {
|
||||
themeStore.quickScrollEnabled = !!value
|
||||
setQuickScrollEnabled(themeStore.quickScrollEnabled)
|
||||
}
|
||||
|
||||
function toggleNavShortcut(action: ShortcutAction, value: unknown) {
|
||||
themeStore[action.enabledField] = !!value
|
||||
setNavShortcutEnabled(action.id, !!value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SettingsSection>
|
||||
<template #header>
|
||||
<h2 class="m-0 text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.scrollTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-sm leading-relaxed text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.scrollDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<SettingsRow>
|
||||
<template #label>
|
||||
<span id="settings-target-shortcuts-enable" tabindex="-1">
|
||||
{{ formatMessage(messages.enable) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="quick-scroll-enabled"
|
||||
:model-value="themeStore.quickScrollEnabled"
|
||||
@update:model-value="toggleQuickScroll"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow v-for="action in SCROLL_ACTIONS" :key="action.id">
|
||||
<template #label>
|
||||
<span :id="`settings-target-${action.id}`" tabindex="-1">{{ actionLabel(action) }}</span>
|
||||
</template>
|
||||
<template #description>{{ actionDescription(action) }}</template>
|
||||
<template #control>
|
||||
<button
|
||||
type="button"
|
||||
class="keybinding-trigger"
|
||||
:aria-label="formatMessage(messages.changeShortcut, { action: actionLabel(action) })"
|
||||
@click="openShortcut(action)"
|
||||
>
|
||||
<KeybindingChips :binding="bindingFor(action)" />
|
||||
</button>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<template #header>
|
||||
<h2
|
||||
id="settings-target-shortcuts-nav"
|
||||
tabindex="-1"
|
||||
class="m-0 text-lg font-semibold text-[var(--color-text-primary)]"
|
||||
>
|
||||
{{ formatMessage(messages.navTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-sm leading-relaxed text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.navDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<SettingsRow v-for="action in NAV_ACTIONS" :key="action.id">
|
||||
<template #label>{{ actionLabel(action) }}</template>
|
||||
<template #description>{{ actionDescription(action) }}</template>
|
||||
<template #control>
|
||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="keybinding-trigger"
|
||||
:aria-label="formatMessage(messages.changeShortcut, { action: actionLabel(action) })"
|
||||
@click="openShortcut(action)"
|
||||
>
|
||||
<KeybindingChips :binding="bindingFor(action)" />
|
||||
</button>
|
||||
<Toggle
|
||||
:id="`nav-shortcut-${action.id}`"
|
||||
:model-value="themeStore[action.enabledField]"
|
||||
@update:model-value="(value) => toggleNavShortcut(action, value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<KeybindingModal
|
||||
ref="keybindingModal"
|
||||
:action-label="editingLabel"
|
||||
:binding="editingBinding"
|
||||
:default-binding="editingDefault"
|
||||
:conflicts="editingConflicts"
|
||||
@save="saveShortcut"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.keybinding-trigger {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
padding: 0.25rem 0.375rem;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.keybinding-trigger:hover,
|
||||
.keybinding-trigger:focus-visible {
|
||||
border-color: var(--surface-4);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { exportLauncherLogs, highlightInFolder } from '@/helpers/utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const exporting = ref(false)
|
||||
const selectedRange = ref<'last30Minutes' | 'last2Hours' | 'all'>('last2Hours')
|
||||
const selectedLevel = ref<'all' | 'debug' | 'info'>('all')
|
||||
const includeSystemInfo = ref(true)
|
||||
const includeInstanceLogs = ref(false)
|
||||
const includeCrashAnalysis = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.settings.logs.export.modal.title', defaultMessage: 'Export logs' },
|
||||
description: {
|
||||
id: 'app.settings.logs.export.modal.description',
|
||||
defaultMessage:
|
||||
'Choose how much detail to include. Exported logs have access tokens, Minecraft tokens, and IP addresses replaced with placeholders.',
|
||||
},
|
||||
rangeLabel: { id: 'app.settings.logs.export.modal.range', defaultMessage: 'Time range' },
|
||||
rangeLast30Minutes: {
|
||||
id: 'app.settings.logs.export.modal.range.30-minutes',
|
||||
defaultMessage: 'Last 30 minutes',
|
||||
},
|
||||
rangeLast2Hours: {
|
||||
id: 'app.settings.logs.export.modal.range.2-hours',
|
||||
defaultMessage: 'Last 2 hours',
|
||||
},
|
||||
rangeAll: { id: 'app.settings.logs.export.modal.range.all', defaultMessage: 'All sessions' },
|
||||
levelLabel: { id: 'app.settings.logs.export.modal.level', defaultMessage: 'Log level' },
|
||||
levelAll: { id: 'app.settings.logs.export.modal.level.all', defaultMessage: 'All levels' },
|
||||
levelDebug: {
|
||||
id: 'app.settings.logs.export.modal.level.debug',
|
||||
defaultMessage: 'Debug and above',
|
||||
},
|
||||
levelInfo: { id: 'app.settings.logs.export.modal.level.info', defaultMessage: 'Info and above' },
|
||||
extrasLabel: {
|
||||
id: 'app.settings.logs.export.modal.extras',
|
||||
defaultMessage: 'Additional information',
|
||||
},
|
||||
systemInfo: {
|
||||
id: 'app.settings.logs.export.modal.system-info',
|
||||
defaultMessage: 'System and environment summary',
|
||||
},
|
||||
instanceLogs: {
|
||||
id: 'app.settings.logs.export.modal.instance-logs',
|
||||
defaultMessage: 'Most recent instance log',
|
||||
},
|
||||
crashAnalysis: {
|
||||
id: 'app.settings.logs.export.modal.crash-analysis',
|
||||
defaultMessage: 'Crash analysis (when a crash is detected)',
|
||||
},
|
||||
cancel: { id: 'app.settings.logs.export.modal.cancel', defaultMessage: 'Cancel' },
|
||||
confirm: { id: 'app.settings.logs.export.modal.confirm', defaultMessage: 'Export' },
|
||||
exportedTitle: {
|
||||
id: 'app.settings.logs.export.notification-title',
|
||||
defaultMessage: 'Logs exported',
|
||||
},
|
||||
exportedText: {
|
||||
id: 'app.settings.logs.export.notification-text',
|
||||
defaultMessage: 'The log archive was saved to {path}',
|
||||
},
|
||||
})
|
||||
|
||||
const rangeOptions = computed(() => [
|
||||
{ value: 'last30Minutes', label: formatMessage(messages.rangeLast30Minutes) },
|
||||
{ value: 'last2Hours', label: formatMessage(messages.rangeLast2Hours) },
|
||||
{ value: 'all', label: formatMessage(messages.rangeAll) },
|
||||
])
|
||||
|
||||
const levelOptions = computed(() => [
|
||||
{ value: 'all', label: formatMessage(messages.levelAll) },
|
||||
{ value: 'debug', label: formatMessage(messages.levelDebug) },
|
||||
{ value: 'info', label: formatMessage(messages.levelInfo) },
|
||||
])
|
||||
|
||||
async function exportLogs() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const path = await exportLauncherLogs({
|
||||
range: selectedRange.value,
|
||||
level: selectedLevel.value,
|
||||
includeSystemInfo: includeSystemInfo.value,
|
||||
includeInstanceLogs: includeInstanceLogs.value,
|
||||
includeCrashAnalysis: includeCrashAnalysis.value,
|
||||
})
|
||||
|
||||
if (path) {
|
||||
modal.value?.hide()
|
||||
popupNotificationManager.addPopupNotification({
|
||||
title: formatMessage(messages.exportedTitle),
|
||||
text: formatMessage(messages.exportedText, { path }),
|
||||
type: 'success',
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(commonMessages.openInFolderButton),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => highlightInFolder(path).catch(handleError),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: () => modal.value?.hide(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
:closable="true"
|
||||
:close-on-click-outside="true"
|
||||
width="40rem"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="m-0">{{ formatMessage(messages.description) }}</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-[var(--color-text-primary)]">{{
|
||||
formatMessage(messages.rangeLabel)
|
||||
}}</span>
|
||||
<Combobox
|
||||
id="log-export-range"
|
||||
v-model="selectedRange"
|
||||
:name="formatMessage(messages.rangeLabel)"
|
||||
:options="rangeOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-[var(--color-text-primary)]">{{
|
||||
formatMessage(messages.levelLabel)
|
||||
}}</span>
|
||||
<Combobox
|
||||
id="log-export-level"
|
||||
v-model="selectedLevel"
|
||||
:name="formatMessage(messages.levelLabel)"
|
||||
:options="levelOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-[var(--color-text-primary)]">{{
|
||||
formatMessage(messages.extrasLabel)
|
||||
}}</span>
|
||||
<Checkbox v-model="includeSystemInfo" :label="formatMessage(messages.systemInfo)" />
|
||||
<Checkbox v-model="includeInstanceLogs" :label="formatMessage(messages.instanceLogs)" />
|
||||
<Checkbox v-model="includeCrashAnalysis" :label="formatMessage(messages.crashAnalysis)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="outlined" :disabled="exporting" @click="modal?.hide()"
|
||||
><XIcon />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" :disabled="exporting" @click="exportLogs"
|
||||
><DownloadIcon />
|
||||
{{ formatMessage(messages.confirm) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { Combobox, defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { type AIState, getAIState, sharedAIState } from '@/helpers/ai'
|
||||
import {
|
||||
get_crash_analysis_ai_settings,
|
||||
get_log_share_settings,
|
||||
update_crash_analysis_ai_settings,
|
||||
update_log_share_settings,
|
||||
} from '@/helpers/logs'
|
||||
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
type CrashAISettings = {
|
||||
enabled: boolean
|
||||
provider_id: string
|
||||
model_id: string
|
||||
ai_source: 'logshare' | 'custom'
|
||||
}
|
||||
|
||||
type LogShareSettings = {
|
||||
share_provider: 'logshare' | 'mclogs'
|
||||
ai_source: string
|
||||
auto_upload: boolean
|
||||
multi_file: boolean
|
||||
no_storage: boolean
|
||||
show_progress: boolean
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const settings = ref<LogShareSettings>({
|
||||
share_provider: 'logshare',
|
||||
ai_source: 'logshare',
|
||||
auto_upload: true,
|
||||
multi_file: true,
|
||||
no_storage: false,
|
||||
show_progress: true,
|
||||
})
|
||||
const aiSettings = ref<CrashAISettings>({
|
||||
enabled: false,
|
||||
provider_id: '',
|
||||
model_id: '',
|
||||
ai_source: 'logshare',
|
||||
})
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.log-share.settings.title', defaultMessage: 'Log sharing & AI analysis' },
|
||||
description: {
|
||||
id: 'app.log-share.settings.description',
|
||||
defaultMessage:
|
||||
'Share crash diagnostics with LogShare for a structured summary and optional AI analysis. LogShare is preferred; mclo.gs is used as a fallback.',
|
||||
},
|
||||
shareProvider: {
|
||||
id: 'app.log-share.settings.share-provider',
|
||||
defaultMessage: 'Log sharing service',
|
||||
},
|
||||
shareProviderDescription: {
|
||||
id: 'app.log-share.settings.share-provider-description',
|
||||
defaultMessage: 'Where the diagnostic link is uploaded when you share it.',
|
||||
},
|
||||
aiSource: { id: 'app.log-share.settings.ai-source', defaultMessage: 'AI analysis source' },
|
||||
aiSourceLogShare: {
|
||||
id: 'app.log-share.settings.ai-source-logshare',
|
||||
defaultMessage: 'LogShare AI',
|
||||
},
|
||||
aiSourceCustom: {
|
||||
id: 'app.log-share.settings.ai-source-custom',
|
||||
defaultMessage: 'Custom AI provider',
|
||||
},
|
||||
aiSourceDescription: {
|
||||
id: 'app.log-share.settings.ai-source-description',
|
||||
defaultMessage:
|
||||
'Both sources send a sanitized and shortened crash context. AI output may be inaccurate.',
|
||||
},
|
||||
enabled: {
|
||||
id: 'app.crash-analysis.ai.settings.enabled',
|
||||
defaultMessage: 'Enable AI explanations',
|
||||
},
|
||||
enabledDescription: {
|
||||
id: 'app.crash-analysis.ai.settings.enabled-description',
|
||||
defaultMessage: 'Local rule-based crash analysis remains available without AI.',
|
||||
},
|
||||
provider: { id: 'app.crash-analysis.ai.settings.provider', defaultMessage: 'AI provider' },
|
||||
model: { id: 'app.crash-analysis.ai.settings.model', defaultMessage: 'Text model' },
|
||||
noProviders: {
|
||||
id: 'app.crash-analysis.ai.settings.no-providers',
|
||||
defaultMessage: 'Enable a provider and a text model above before using AI crash explanations.',
|
||||
},
|
||||
autoUpload: {
|
||||
id: 'app.log-share.settings.auto-upload',
|
||||
defaultMessage: 'Upload to get a structured summary automatically',
|
||||
},
|
||||
autoUploadDescription: {
|
||||
id: 'app.log-share.settings.auto-upload-description',
|
||||
defaultMessage: 'Only effective in LogShare AI mode.',
|
||||
},
|
||||
multiFile: { id: 'app.log-share.settings.multi-file', defaultMessage: 'Multi-file upload' },
|
||||
multiFileDescription: {
|
||||
id: 'app.log-share.settings.multi-file-description',
|
||||
defaultMessage: 'Upload latest.log, crash reports, hs_err and launcher logs together.',
|
||||
},
|
||||
noStorage: {
|
||||
id: 'app.log-share.settings.no-storage',
|
||||
defaultMessage: 'Analyze without storing logs',
|
||||
},
|
||||
noStorageDescription: {
|
||||
id: 'app.log-share.settings.no-storage-description',
|
||||
defaultMessage:
|
||||
'Submit content directly for analysis and skip the stored upload. Disables the automatic summary.',
|
||||
},
|
||||
showProgress: {
|
||||
id: 'app.log-share.settings.show-progress',
|
||||
defaultMessage: 'Show AI analysis progress',
|
||||
},
|
||||
showProgressDescription: {
|
||||
id: 'app.log-share.settings.show-progress-description',
|
||||
defaultMessage: 'Display thinking, tool and limit status while LogShare AI is working.',
|
||||
},
|
||||
})
|
||||
|
||||
const emptyState: AIState = { settings: { enabled: false }, catalog_source: '', providers: [] }
|
||||
const aiState = computed(() => sharedAIState.value ?? emptyState)
|
||||
const providers = computed(() =>
|
||||
aiState.value.providers.filter(
|
||||
(provider) => provider.enabled && provider.models.some((model) => model.enabled),
|
||||
),
|
||||
)
|
||||
const providerOptions = computed(() =>
|
||||
providers.value.map((provider) => ({
|
||||
value: provider.provider_id,
|
||||
label: provider.custom_name || provider.provider_id,
|
||||
})),
|
||||
)
|
||||
const modelOptions = computed(
|
||||
() =>
|
||||
providers.value
|
||||
.find((provider) => provider.provider_id === aiSettings.value.provider_id)
|
||||
?.models.filter((model) => model.enabled)
|
||||
.map((model) => ({ value: model.id, label: model.name || model.id })) ?? [],
|
||||
)
|
||||
|
||||
const isCustom = computed(() => aiSettings.value.ai_source === 'custom')
|
||||
const noStorageEnabled = computed(() => aiSettings.value.ai_source === 'logshare')
|
||||
|
||||
const shareProviderOptions = [
|
||||
{ value: 'logshare', label: 'LogShare' },
|
||||
{ value: 'mclogs', label: 'mclo.gs' },
|
||||
]
|
||||
const aiSourceOptions = [
|
||||
{ value: 'logshare', label: formatMessage(messages.aiSourceLogShare) },
|
||||
{ value: 'custom', label: formatMessage(messages.aiSourceCustom) },
|
||||
]
|
||||
|
||||
async function saveShare(next: LogShareSettings): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
await update_log_share_settings(next)
|
||||
settings.value = next
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAI(next: CrashAISettings): Promise<void> {
|
||||
saving.value = true
|
||||
try {
|
||||
await update_crash_analysis_ai_settings(next)
|
||||
aiSettings.value = next
|
||||
settings.value = { ...settings.value, ai_source: next.ai_source }
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateShare(patch: Partial<LogShareSettings>): void {
|
||||
void saveShare({ ...settings.value, ...patch })
|
||||
}
|
||||
|
||||
function selectShareProvider(value: unknown): void {
|
||||
const provider = value === 'mclogs' ? 'mclogs' : 'logshare'
|
||||
updateShare({ share_provider: provider, ai_source: aiSettings.value.ai_source })
|
||||
}
|
||||
|
||||
function selectAiSource(value: unknown): void {
|
||||
const source = value === 'custom' ? 'custom' : 'logshare'
|
||||
void saveAI({ ...aiSettings.value, ai_source: source })
|
||||
}
|
||||
|
||||
function updateEnabled(enabled: boolean): void {
|
||||
void saveAI({ ...aiSettings.value, enabled })
|
||||
}
|
||||
|
||||
function updateProvider(providerId: string): void {
|
||||
const provider = providers.value.find((item) => item.provider_id === providerId)
|
||||
const modelId = provider?.models.find((model) => model.enabled)?.id ?? ''
|
||||
void saveAI({ ...aiSettings.value, provider_id: providerId, model_id: modelId })
|
||||
}
|
||||
|
||||
function updateModel(modelId: string): void {
|
||||
void saveAI({ ...aiSettings.value, model_id: modelId })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [nextShare, nextAI] = await Promise.all([
|
||||
get_log_share_settings(),
|
||||
get_crash_analysis_ai_settings(),
|
||||
getAIState(),
|
||||
])
|
||||
settings.value = { ...nextShare, ai_source: nextAI.ai_source }
|
||||
aiSettings.value = nextAI
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSection
|
||||
v-if="!loading"
|
||||
id="settings-target-log-share"
|
||||
:title="formatMessage(messages.title)"
|
||||
:description="formatMessage(messages.description)"
|
||||
>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.shareProvider) }}</template>
|
||||
<template #description>{{ formatMessage(messages.shareProviderDescription) }}</template>
|
||||
<template #control>
|
||||
<Combobox
|
||||
id="log-share-provider"
|
||||
:model-value="settings.share_provider"
|
||||
:options="shareProviderOptions"
|
||||
:disabled="saving"
|
||||
@update:model-value="selectShareProvider($event)"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.aiSource) }}</template>
|
||||
<template #description>{{ formatMessage(messages.aiSourceDescription) }}</template>
|
||||
<template #control>
|
||||
<Combobox
|
||||
id="log-share-ai-source"
|
||||
:model-value="aiSettings.ai_source"
|
||||
:options="aiSourceOptions"
|
||||
:disabled="saving"
|
||||
@update:model-value="selectAiSource($event)"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<template v-if="isCustom">
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.enabled) }}</template>
|
||||
<template #description>{{ formatMessage(messages.enabledDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="crash-analysis-ai-enabled"
|
||||
:model-value="aiSettings.enabled"
|
||||
:disabled="saving || !aiState.settings.enabled || !providers.length"
|
||||
@update:model-value="updateEnabled(!!$event)"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<template v-if="providers.length">
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.provider) }}</template>
|
||||
<template #control>
|
||||
<Combobox
|
||||
id="crash-analysis-ai-provider"
|
||||
:model-value="aiSettings.provider_id"
|
||||
:options="providerOptions"
|
||||
:disabled="saving"
|
||||
@update:model-value="updateProvider(String($event))"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.model) }}</template>
|
||||
<template #control>
|
||||
<Combobox
|
||||
id="crash-analysis-ai-model"
|
||||
:model-value="aiSettings.model_id"
|
||||
:options="modelOptions"
|
||||
:disabled="saving || !aiSettings.provider_id"
|
||||
@update:model-value="updateModel(String($event))"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</template>
|
||||
<p v-else class="m-0 p-4 text-sm text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.noProviders) }}
|
||||
</p>
|
||||
</template>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.autoUpload) }}</template>
|
||||
<template #description>{{ formatMessage(messages.autoUploadDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="log-share-auto-upload"
|
||||
:model-value="settings.auto_upload"
|
||||
:disabled="saving || !noStorageEnabled || settings.no_storage"
|
||||
@update:model-value="updateShare({ auto_upload: !!$event })"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.multiFile) }}</template>
|
||||
<template #description>{{ formatMessage(messages.multiFileDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="log-share-multi-file"
|
||||
:model-value="settings.multi_file"
|
||||
:disabled="saving"
|
||||
@update:model-value="updateShare({ multi_file: !!$event })"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.noStorage) }}</template>
|
||||
<template #description>{{ formatMessage(messages.noStorageDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="log-share-no-storage"
|
||||
:model-value="settings.no_storage"
|
||||
:disabled="saving || !noStorageEnabled"
|
||||
@update:model-value="updateShare({ no_storage: !!$event })"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<SettingsRow>
|
||||
<template #label>{{ formatMessage(messages.showProgress) }}</template>
|
||||
<template #description>{{ formatMessage(messages.showProgressDescription) }}</template>
|
||||
<template #control>
|
||||
<Toggle
|
||||
id="log-share-show-progress"
|
||||
:model-value="settings.show_progress"
|
||||
:disabled="saving"
|
||||
@update:model-value="updateShare({ show_progress: !!$event })"
|
||||
/>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderOpenIcon, UploadIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { showLauncherLogsFolder } from '@/helpers/utils'
|
||||
|
||||
import LogExportModal from './LogExportModal.vue'
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const exportModal = ref<InstanceType<typeof LogExportModal>>()
|
||||
|
||||
const messages = defineMessages({
|
||||
exportTitle: { id: 'app.settings.logs.export.title', defaultMessage: 'Export logs' },
|
||||
exportDescription: {
|
||||
id: 'app.settings.logs.export.description',
|
||||
defaultMessage:
|
||||
'Package launcher logs with an optional environment summary, instance log, and crash analysis.',
|
||||
},
|
||||
exportButton: { id: 'app.settings.logs.export.button', defaultMessage: 'Export logs…' },
|
||||
openFolder: { id: 'app.settings.logs.open-folder', defaultMessage: 'Open logs folder' },
|
||||
})
|
||||
|
||||
async function openLogsFolder() {
|
||||
try {
|
||||
await showLauncherLogsFolder()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SettingsSection
|
||||
:title="formatMessage(messages.exportTitle)"
|
||||
:description="formatMessage(messages.exportDescription)"
|
||||
title-id="settings-target-logs-export"
|
||||
>
|
||||
<SettingsRow stacked>
|
||||
<template #label>
|
||||
<span>{{ formatMessage(messages.exportTitle) }}</span>
|
||||
</template>
|
||||
<template #description>{{ formatMessage(messages.exportDescription) }}</template>
|
||||
<template #control>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="colored" color="brand" @click="exportModal?.show()"
|
||||
><UploadIcon />
|
||||
{{ formatMessage(messages.exportButton) }}
|
||||
</Button>
|
||||
<Button @click="openLogsFolder"
|
||||
><FolderOpenIcon />
|
||||
{{ formatMessage(messages.openFolder) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
|
||||
<LogExportModal ref="exportModal" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -253,6 +253,15 @@ const messages = defineMessages({
|
||||
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.',
|
||||
},
|
||||
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: {
|
||||
id: 'app.settings.resources.mojang-auth-service',
|
||||
defaultMessage: 'Mojang authentication service',
|
||||
@@ -923,6 +932,15 @@ function validateMinecraftDirectory(value) {
|
||||
/>
|
||||
</template>
|
||||
</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 v-if="props.scope === 'content-downloads'">
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { delete_shared_log, list_shared_logs } from '@/helpers/logs'
|
||||
|
||||
import SettingsRow from './SettingsRow.vue'
|
||||
import SettingsSection from './SettingsSection.vue'
|
||||
|
||||
type SharedLog = {
|
||||
id: string
|
||||
url: string
|
||||
raw: string
|
||||
token: string
|
||||
provider: string
|
||||
instance_id: string | null
|
||||
instance_name: string | null
|
||||
truncated: boolean
|
||||
created_at: number
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const logs = ref<SharedLog[]>([])
|
||||
const loading = ref(true)
|
||||
const deletingId = ref<string | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.log-share.shared-logs.title', defaultMessage: 'Shared logs' },
|
||||
description: {
|
||||
id: 'app.log-share.shared-logs.description',
|
||||
defaultMessage:
|
||||
'Every diagnostic you have shared is listed here. Delete an entry to remove it from your history and request its removal from the sharing service.',
|
||||
},
|
||||
empty: { id: 'app.log-share.shared-logs.empty', defaultMessage: 'No shared logs yet.' },
|
||||
delete: { id: 'app.log-share.shared-logs.delete', defaultMessage: 'Delete' },
|
||||
deleting: { id: 'app.log-share.shared-logs.deleting', defaultMessage: 'Deleting…' },
|
||||
deleted: {
|
||||
id: 'app.log-share.shared-logs.deleted',
|
||||
defaultMessage: 'Shared log removed.',
|
||||
},
|
||||
deleteFailed: {
|
||||
id: 'app.log-share.shared-logs.delete-failed',
|
||||
defaultMessage: 'Failed to delete the shared log.',
|
||||
},
|
||||
truncated: { id: 'app.log-share.shared-logs.truncated', defaultMessage: 'Truncated' },
|
||||
})
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
logs.value = await list_shared_logs()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(log: SharedLog): Promise<void> {
|
||||
if (deletingId.value) return
|
||||
deletingId.value = log.id
|
||||
try {
|
||||
await delete_shared_log(log.id, log.token)
|
||||
logs.value = logs.value.filter((item) => item.id !== log.id)
|
||||
addNotification({ title: formatMessage(messages.deleted), type: 'success' })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
addNotification({ title: formatMessage(messages.deleteFailed), type: 'error' })
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSection
|
||||
id="settings-target-shared-logs"
|
||||
:title="formatMessage(messages.title)"
|
||||
:description="formatMessage(messages.description)"
|
||||
>
|
||||
<SettingsRow stacked>
|
||||
<template #control>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<p v-if="loading" class="m-0 p-2 text-sm text-[var(--color-text-tertiary)]">…</p>
|
||||
<p v-else-if="!logs.length" class="m-0 p-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<ul v-else class="m-0 flex max-h-72 list-none flex-col gap-1 overflow-y-auto p-2">
|
||||
<li
|
||||
v-for="log in logs"
|
||||
:key="log.id"
|
||||
class="flex items-center gap-3 rounded-lg bg-surface-2 px-3 py-2"
|
||||
>
|
||||
<a
|
||||
:href="log.url || `https://logshare.cn/${log.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="min-w-0 flex-1 truncate text-[var(--color-text-default)] underline"
|
||||
>
|
||||
{{ log.url || log.id }}
|
||||
</a>
|
||||
<span class="shrink-0 text-xs text-[var(--color-text-tertiary)]">
|
||||
{{ log.provider }}
|
||||
<template v-if="log.truncated">
|
||||
· {{ formatMessage(messages.truncated) }}
|
||||
</template>
|
||||
<template v-if="log.instance_name"> · {{ log.instance_name }} </template>
|
||||
· {{ formatDate(log.created_at) }}
|
||||
</span>
|
||||
<Button type="outlined" :disabled="deletingId === log.id" @click="remove(log)"
|
||||
>{{
|
||||
deletingId === log.id
|
||||
? formatMessage(messages.deleting)
|
||||
: formatMessage(messages.delete)
|
||||
}}
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import BackupRepositorySettings from './BackupRepositorySettings.vue'
|
||||
import ResourceManagementSettings from './ResourceManagementSettings.vue'
|
||||
import StorageSettings from './StorageSettings.vue'
|
||||
</script>
|
||||
@@ -6,6 +7,7 @@ import StorageSettings from './StorageSettings.vue'
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<ResourceManagementSettings scope="storage-backups" />
|
||||
<BackupRepositorySettings />
|
||||
<StorageSettings />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -3,6 +3,7 @@ import type { MessageDescriptor } from '@modrinth/ui'
|
||||
export type SettingsCategoryId =
|
||||
| 'interface'
|
||||
| 'home-navigation'
|
||||
| 'shortcut-settings'
|
||||
| 'language-translation'
|
||||
| 'ai'
|
||||
| 'java-performance'
|
||||
@@ -13,6 +14,7 @@ export type SettingsCategoryId =
|
||||
| 'updates'
|
||||
| 'about'
|
||||
| 'feature-flags'
|
||||
| 'logs'
|
||||
|
||||
export type SettingsGroupId = 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer'
|
||||
|
||||
@@ -44,6 +46,15 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
|
||||
group: 'launcher',
|
||||
onboardingId: 'settings-tab-home-navigation',
|
||||
},
|
||||
{
|
||||
id: 'shortcut-settings',
|
||||
name: {
|
||||
id: 'app.settings.tabs.shortcut-settings',
|
||||
defaultMessage: 'Keyboard shortcuts',
|
||||
},
|
||||
group: 'launcher',
|
||||
onboardingId: 'settings-tab-shortcut-settings',
|
||||
},
|
||||
{
|
||||
id: 'language-translation',
|
||||
name: {
|
||||
@@ -116,6 +127,11 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
|
||||
name: { id: 'app.settings.tabs.about', defaultMessage: 'About' },
|
||||
group: 'support',
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: { id: 'app.settings.tabs.logs', defaultMessage: 'Logs' },
|
||||
group: 'support',
|
||||
},
|
||||
{
|
||||
id: 'feature-flags',
|
||||
name: {
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
BotIcon,
|
||||
CoffeeIcon,
|
||||
CpuIcon,
|
||||
FileTextIcon,
|
||||
GameIcon,
|
||||
GaugeIcon,
|
||||
InfoIcon,
|
||||
KeyboardIcon,
|
||||
LanguagesIcon,
|
||||
LayoutTemplateIcon,
|
||||
PaintbrushIcon,
|
||||
@@ -48,6 +50,10 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
|
||||
icon: LayoutTemplateIcon,
|
||||
content: defineAsyncComponent(() => import('./HomeNavigationSettings.vue')),
|
||||
},
|
||||
'shortcut-settings': {
|
||||
icon: KeyboardIcon,
|
||||
content: defineAsyncComponent(() => import('./KeybindSettings.vue')),
|
||||
},
|
||||
'language-translation': {
|
||||
icon: LanguagesIcon,
|
||||
content: defineAsyncComponent(() => import('./LanguageTranslationSettings.vue')),
|
||||
@@ -78,6 +84,10 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
|
||||
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
|
||||
},
|
||||
about: { icon: InfoIcon, content: defineAsyncComponent(() => import('./AboutSettings.vue')) },
|
||||
logs: {
|
||||
icon: FileTextIcon,
|
||||
content: defineAsyncComponent(() => import('./LogsSettings.vue')),
|
||||
},
|
||||
'feature-flags': {
|
||||
icon: ToggleRightIcon,
|
||||
content: defineAsyncComponent(() => import('./FeatureFlagSettings.vue')),
|
||||
|
||||
@@ -384,6 +384,16 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
|
||||
categoryId: 'feature-flags',
|
||||
label: message('settings.feature-flags.title', 'Feature flags'),
|
||||
},
|
||||
{
|
||||
id: 'logs-export',
|
||||
categoryId: 'logs',
|
||||
targetId: 'settings-target-logs-export',
|
||||
label: message('app.settings.logs.export.title', 'Export logs'),
|
||||
description: message(
|
||||
'app.settings.logs.export.description',
|
||||
'Package launcher logs with an optional environment summary, instance log, and crash analysis.',
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function validateSettingsSearchEntries(entries = settingsSearchEntries): string[] {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
const settingsComponentFiles = {
|
||||
interface: ['./AppearanceSettings.vue'],
|
||||
'home-navigation': ['./AppearanceSettings.vue'],
|
||||
'shortcut-settings': ['./KeybindSettings.vue'],
|
||||
'language-translation': ['./LanguageSettings.vue', './TranslationSettings.vue'],
|
||||
ai: ['./AISettings.vue'],
|
||||
'java-performance': ['./JavaSettings.vue'],
|
||||
@@ -30,6 +31,7 @@ const settingsComponentFiles = {
|
||||
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
|
||||
updates: ['./UpdateSettings.vue'],
|
||||
about: ['./AboutSettings.vue'],
|
||||
logs: ['./LogsSettings.vue'],
|
||||
'feature-flags': ['./FeatureFlagSettings.vue'],
|
||||
} as const
|
||||
|
||||
@@ -168,8 +170,7 @@ test('developer-only settings stay out of the normal search categories', () => {
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
test('settings navigation groups preserve the intended Starlight information architecture', () => {
|
||||
test('settings navigation groups preserve the intended Starlight information architecture', () => {
|
||||
const categoriesForGroup = (
|
||||
group: 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer',
|
||||
developerMode = false,
|
||||
@@ -181,6 +182,7 @@ test('developer-only settings stay out of the normal search categories', () => {
|
||||
assert.deepEqual(categoriesForGroup('launcher'), [
|
||||
'interface',
|
||||
'home-navigation',
|
||||
'shortcut-settings',
|
||||
'language-translation',
|
||||
'ai',
|
||||
])
|
||||
@@ -191,7 +193,7 @@ test('developer-only settings stay out of the normal search categories', () => {
|
||||
'network-multiplayer',
|
||||
])
|
||||
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups'])
|
||||
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
|
||||
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about', 'logs'])
|
||||
assert.deepEqual(categoriesForGroup('developer'), [])
|
||||
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { type SkinSiteChatUserInfo, skinSiteChatUserSearch } from '@/helpers/skin-site-api'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
roomId?: number
|
||||
label?: string
|
||||
placeholder?: string
|
||||
excludeUuids?: string[]
|
||||
allowedUuids?: string[]
|
||||
allowedHint?: string
|
||||
}>(),
|
||||
{
|
||||
roomId: undefined,
|
||||
label: undefined,
|
||||
placeholder: undefined,
|
||||
excludeUuids: () => [],
|
||||
allowedUuids: undefined,
|
||||
allowedHint: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<SkinSiteChatUserInfo | null>({ required: true })
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'app.skin-site.chat.user-picker.label', defaultMessage: 'Pick a user' },
|
||||
placeholder: {
|
||||
id: 'app.skin-site.chat.user-picker.placeholder',
|
||||
defaultMessage: 'Search username, email or account ID',
|
||||
},
|
||||
byUser: { id: 'app.skin-site.chat.user-picker.by-user', defaultMessage: 'By user' },
|
||||
byPlayer: { id: 'app.skin-site.chat.user-picker.by-player', defaultMessage: 'By player' },
|
||||
hint: {
|
||||
id: 'app.skin-site.chat.user-picker.hint',
|
||||
defaultMessage: 'Fuzzy search on username and email, or search by account ID.',
|
||||
},
|
||||
playerHint: {
|
||||
id: 'app.skin-site.chat.user-picker.player-hint',
|
||||
defaultMessage: 'Enter a player name to find the bound user.',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.skin-site.chat.user-picker.empty',
|
||||
defaultMessage: 'No matching user.',
|
||||
},
|
||||
banned: {
|
||||
id: 'app.skin-site.chat.user-picker.banned',
|
||||
defaultMessage: 'This account is banned.',
|
||||
},
|
||||
guest: {
|
||||
id: 'app.skin-site.chat.user-picker.guest',
|
||||
defaultMessage: 'Guests cannot become a manager or settlement owner.',
|
||||
},
|
||||
alreadyIn: {
|
||||
id: 'app.skin-site.chat.user-picker.already-in',
|
||||
defaultMessage: 'This user is already in the room.',
|
||||
},
|
||||
notAllowed: {
|
||||
id: 'app.skin-site.chat.user-picker.not-allowed',
|
||||
defaultMessage: 'This user is outside the allowed range.',
|
||||
},
|
||||
select: { id: 'app.skin-site.chat.user-picker.select', defaultMessage: 'Select' },
|
||||
selected: {
|
||||
id: 'app.skin-site.chat.user-picker.selected',
|
||||
defaultMessage: 'Selected',
|
||||
},
|
||||
idLabel: { id: 'app.skin-site.chat.user-picker.id', defaultMessage: 'ID' },
|
||||
emailLabel: { id: 'app.skin-site.chat.user-picker.email', defaultMessage: 'Email' },
|
||||
registeredLabel: {
|
||||
id: 'app.skin-site.chat.user-picker.registered',
|
||||
defaultMessage: 'Registered',
|
||||
},
|
||||
bannedStatus: { id: 'app.skin-site.chat.user-picker.banned-status', defaultMessage: 'Banned' },
|
||||
activeStatus: { id: 'app.skin-site.chat.user-picker.active-status', defaultMessage: 'Active' },
|
||||
})
|
||||
|
||||
const keyword = ref('')
|
||||
const searchMode = ref<'user' | 'player'>('user')
|
||||
const results = ref<SkinSiteChatUserInfo[]>([])
|
||||
const preview = ref<SkinSiteChatUserInfo | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let requestId = 0
|
||||
|
||||
const currentLabel = computed(() => props.label ?? formatMessage(messages.label))
|
||||
const currentPlaceholder = computed(() => props.placeholder ?? formatMessage(messages.placeholder))
|
||||
|
||||
watch([keyword, searchMode], () => {
|
||||
clearTimeout(timer)
|
||||
const query = keyword.value.trim()
|
||||
if (!query) {
|
||||
results.value = []
|
||||
preview.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
timer = setTimeout(() => void search(query), 300)
|
||||
})
|
||||
|
||||
watch(model, (value) => {
|
||||
if (value) return
|
||||
keyword.value = ''
|
||||
preview.value = null
|
||||
results.value = []
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.roomId,
|
||||
() => {
|
||||
model.value = null
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
|
||||
async function search(query: string) {
|
||||
const current = ++requestId
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await skinSiteChatUserSearch(query, props.roomId, searchMode.value)
|
||||
if (current !== requestId) return
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
results.value = res.payload ?? []
|
||||
preview.value = results.value.length === 1 ? results.value[0] : null
|
||||
} catch (error) {
|
||||
if (current === requestId) {
|
||||
results.value = []
|
||||
handleError(error)
|
||||
}
|
||||
} finally {
|
||||
if (current === requestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableReason(user: SkinSiteChatUserInfo): string | null {
|
||||
if (user.banned) return formatMessage(messages.banned)
|
||||
if (user.permissions.includes('GUEST')) return formatMessage(messages.guest)
|
||||
if (props.excludeUuids.includes(user.uuid)) return formatMessage(messages.alreadyIn)
|
||||
if (props.allowedUuids && !props.allowedUuids.includes(user.uuid)) {
|
||||
return props.allowedHint ?? formatMessage(messages.notAllowed)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function selectPreview() {
|
||||
if (preview.value && !unavailableReason(preview.value)) {
|
||||
model.value = preview.value
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString()
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-semibold text-secondary">{{ currentLabel }}</span>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
v-model="searchMode"
|
||||
class="shrink-0 rounded-lg border border-solid border-surface-5 bg-bg-raised px-2 py-1.5 text-xs text-contrast outline-none focus:border-brand"
|
||||
>
|
||||
<option value="user">{{ formatMessage(messages.byUser) }}</option>
|
||||
<option value="player">{{ formatMessage(messages.byPlayer) }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-model.trim="keyword"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:placeholder="
|
||||
searchMode === 'player' ? formatMessage(messages.playerHint) : currentPlaceholder
|
||||
"
|
||||
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[11px] text-secondary">
|
||||
{{
|
||||
searchMode === 'player'
|
||||
? formatMessage(messages.playerHint)
|
||||
: formatMessage(messages.hint)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="keyword && !loading"
|
||||
class="flex max-h-56 flex-col gap-1.5 overflow-y-auto rounded-lg border border-solid border-surface-5 bg-bg-raised p-2"
|
||||
>
|
||||
<button
|
||||
v-for="user in results"
|
||||
:key="user.uuid"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-lg border border-solid p-2 text-left transition-colors"
|
||||
:class="
|
||||
preview?.uuid === user.uuid
|
||||
? 'border-brand bg-brand/10'
|
||||
: 'border-transparent bg-bg hover:border-surface-5'
|
||||
"
|
||||
@click="preview = user"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class="grid size-8 shrink-0 place-items-center rounded-lg bg-brand/15 text-xs font-bold text-brand"
|
||||
>
|
||||
{{ user.username.slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<strong class="block truncate text-xs text-contrast">{{ user.username }}</strong>
|
||||
<span class="block truncate text-[10px] text-secondary">
|
||||
{{ formatMessage(messages.idLabel) }} {{ user.id }} · {{ user.email }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="unavailableReason(user)"
|
||||
class="shrink-0 rounded-full bg-red/20 px-2 py-0.5 text-[10px] font-semibold text-red"
|
||||
>
|
||||
{{ unavailableReason(user) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="model?.uuid === user.uuid"
|
||||
class="shrink-0 rounded-full bg-brand px-2 py-0.5 text-[10px] font-semibold text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.selected) }}
|
||||
</span>
|
||||
</button>
|
||||
<p v-if="!results.length" class="m-0 py-4 text-center text-xs text-secondary">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="preview"
|
||||
class="flex flex-col gap-3 rounded-lg border border-solid border-surface-5 bg-bg-raised p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-sm font-bold text-contrast">{{ preview.username }}</h3>
|
||||
<p class="m-0 text-[11px] text-secondary">
|
||||
{{ formatMessage(messages.idLabel) }} {{ preview.id }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="preview.banned ? 'bg-red text-contrast' : 'bg-green text-contrast'"
|
||||
>
|
||||
{{ formatMessage(preview.banned ? messages.bannedStatus : messages.activeStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid gap-2 text-[11px] sm:grid-cols-2">
|
||||
<div class="min-w-0">
|
||||
<span class="block text-secondary">UUID</span>
|
||||
<span class="block break-all font-mono text-contrast">{{ preview.uuid }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-secondary">{{ formatMessage(messages.emailLabel) }}</span>
|
||||
<span class="block break-all text-contrast">{{ preview.email }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-secondary">QQ</span>
|
||||
<span class="block text-contrast">{{ preview.qq ?? '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-secondary">{{ formatMessage(messages.registeredLabel) }}</span>
|
||||
<span class="block text-contrast">{{ formatTime(preview.registerTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="perm in preview.permissions"
|
||||
:key="perm"
|
||||
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
|
||||
>
|
||||
{{ perm }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="unavailableReason(preview)" class="m-0 text-[11px] text-red">
|
||||
{{ unavailableReason(preview) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg bg-brand px-3 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="Boolean(unavailableReason(preview))"
|
||||
@click="selectPreview"
|
||||
>
|
||||
{{
|
||||
model?.uuid === preview.uuid
|
||||
? formatMessage(messages.selected)
|
||||
: formatMessage(messages.select)
|
||||
}}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { loadCachedRoomAvatar } from './room-avatar-cache'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
roomId: number
|
||||
avatarAttachmentId?: number | null
|
||||
name: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}>(),
|
||||
{ avatarAttachmentId: null, size: 'md' },
|
||||
)
|
||||
|
||||
const avatarUrl = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const sizeClass = computed(
|
||||
() =>
|
||||
({
|
||||
sm: 'size-9 rounded-lg text-lg',
|
||||
md: 'size-10 rounded-lg text-xl',
|
||||
lg: 'size-12 rounded-lg text-2xl',
|
||||
xl: 'size-20 rounded-xl text-3xl',
|
||||
})[props.size],
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.roomId, props.avatarAttachmentId] as const,
|
||||
async ([roomId, attachmentId]) => {
|
||||
avatarUrl.value = null
|
||||
if (!attachmentId) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
const url = await loadCachedRoomAvatar(roomId, attachmentId)
|
||||
if (props.roomId === roomId && props.avatarAttachmentId === attachmentId) {
|
||||
avatarUrl.value = url
|
||||
}
|
||||
loading.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid shrink-0 place-items-center overflow-hidden border border-solid border-surface-5 bg-brand/10 font-bold text-brand"
|
||||
:class="sizeClass"
|
||||
>
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
:alt="name"
|
||||
class="h-full w-full object-cover"
|
||||
decoding="async"
|
||||
/>
|
||||
<span v-else-if="loading" class="text-[10px] text-secondary">…</span>
|
||||
<span v-else class="select-none">#</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { skinSiteChatAttachmentUpload } from '@/helpers/skin-site-api'
|
||||
|
||||
import RoomAvatar from './RoomAvatar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError, addNotification } = injectNotificationManager()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
roomId?: number
|
||||
avatarAttachmentId?: number | null
|
||||
name: string
|
||||
token?: string | null
|
||||
remove?: boolean
|
||||
}>(),
|
||||
{ roomId: 0, avatarAttachmentId: null, token: null, remove: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:token', value: string | null): void
|
||||
(e: 'update:remove', value: boolean): void
|
||||
(e: 'update:uploading', value: boolean): void
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'app.skin-site.chat.avatar.label', defaultMessage: 'Room avatar' },
|
||||
upload: { id: 'app.skin-site.chat.avatar.upload', defaultMessage: 'Upload avatar' },
|
||||
replace: { id: 'app.skin-site.chat.avatar.replace', defaultMessage: 'Replace avatar' },
|
||||
remove: { id: 'app.skin-site.chat.avatar.remove', defaultMessage: 'Remove avatar' },
|
||||
hint: {
|
||||
id: 'app.skin-site.chat.avatar.hint',
|
||||
defaultMessage: 'PNG, JPEG or WebP, up to 8192 px per side and 5 MB.',
|
||||
},
|
||||
badFormat: {
|
||||
id: 'app.skin-site.chat.avatar.bad-format',
|
||||
defaultMessage: 'Only PNG, JPEG and WebP are supported.',
|
||||
},
|
||||
tooLarge: {
|
||||
id: 'app.skin-site.chat.avatar.too-large',
|
||||
defaultMessage: 'Avatar must not exceed 5 MB.',
|
||||
},
|
||||
notImage: {
|
||||
id: 'app.skin-site.chat.avatar.not-image',
|
||||
defaultMessage: 'Avatar must be an image.',
|
||||
},
|
||||
})
|
||||
|
||||
const MAX_BYTES = 5 * 1024 * 1024
|
||||
const ALLOWED = ['png', 'jpg', 'jpeg', 'webp']
|
||||
|
||||
const uploading = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.token,
|
||||
(token) => {
|
||||
if (!token) revokePreview()
|
||||
},
|
||||
)
|
||||
|
||||
function revokePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = null
|
||||
}
|
||||
|
||||
async function selectAvatar(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
if (!ext || !ALLOWED.includes(ext)) {
|
||||
addNotification({ type: 'error', title: formatMessage(messages.badFormat) })
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
addNotification({ type: 'error', title: formatMessage(messages.tooLarge) })
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
emit('update:uploading', true)
|
||||
try {
|
||||
const res = await skinSiteChatAttachmentUpload(file)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
if (res.payload.attachment.kind !== 'IMAGE') {
|
||||
addNotification({ type: 'error', title: formatMessage(messages.notImage) })
|
||||
return
|
||||
}
|
||||
revokePreview()
|
||||
previewUrl.value = URL.createObjectURL(file)
|
||||
emit('update:token', res.payload.token)
|
||||
emit('update:remove', false)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
emit('update:uploading', false)
|
||||
}
|
||||
}
|
||||
|
||||
function removeAvatar() {
|
||||
revokePreview()
|
||||
emit('update:token', null)
|
||||
emit('update:remove', true)
|
||||
}
|
||||
|
||||
onBeforeUnmount(revokePreview)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.label) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div
|
||||
v-if="previewUrl"
|
||||
class="size-20 overflow-hidden rounded-xl border border-solid border-surface-5 bg-bg-raised"
|
||||
>
|
||||
<img :src="previewUrl" alt="" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
<RoomAvatar
|
||||
v-else
|
||||
:room-id="roomId"
|
||||
:avatar-attachment-id="remove ? null : avatarAttachmentId"
|
||||
:name="name || 'Room'"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
class="cursor-pointer rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
:class="uploading ? 'pointer-events-none opacity-50' : ''"
|
||||
>
|
||||
{{ uploading ? '…' : (avatarAttachmentId || previewUrl ? formatMessage(messages.replace) : formatMessage(messages.upload)) }}
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
@change="selectAvatar"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
v-if="previewUrl || (avatarAttachmentId && !remove)"
|
||||
type="button"
|
||||
class="rounded-lg bg-red/20 px-3 py-1.5 text-xs font-semibold text-red hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="uploading"
|
||||
@click="removeAvatar"
|
||||
>
|
||||
{{ formatMessage(messages.remove) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 text-[11px] text-secondary">{{ formatMessage(messages.hint) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { SparklesIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
requestSkinSiteLuck,
|
||||
skinSitePlayers,
|
||||
skinSitePlayersStatus,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
} from '@/composables/skin-site-session'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
signedOutTitle: { id: 'app.skin-site.home.signed-out-title', defaultMessage: 'Not signed in' },
|
||||
signedOutBody: {
|
||||
id: 'app.skin-site.home.signed-out-body',
|
||||
defaultMessage: 'Sign in to the StarLight skin site to manage players and download packs.',
|
||||
},
|
||||
players: { id: 'app.skin-site.home.players', defaultMessage: 'Your players' },
|
||||
noPlayers: {
|
||||
id: 'app.skin-site.home.no-players',
|
||||
defaultMessage: 'No players yet. Add one on the skin site.',
|
||||
},
|
||||
luck: { id: 'app.skin-site.home.luck', defaultMessage: "Today's luck" },
|
||||
luckUnavailable: { id: 'app.skin-site.home.luck-unavailable', defaultMessage: 'Unavailable' },
|
||||
loading: { id: 'app.skin-site.home.loading', defaultMessage: 'Loading…' },
|
||||
error: { id: 'app.skin-site.home.error', defaultMessage: 'Could not load players.' },
|
||||
signIn: { id: 'app.skin-site.home.sign-in', defaultMessage: 'Sign in' },
|
||||
settings: { id: 'app.skin-site.home.settings', defaultMessage: 'Account' },
|
||||
admin: { id: 'app.skin-site.home.admin', defaultMessage: 'Admin' },
|
||||
myPlayers: { id: 'app.skin-site.home.my-players', defaultMessage: 'My players' },
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'sign-in' | 'open-settings' | 'open-players' | 'open-admin'): void
|
||||
}>()
|
||||
|
||||
const luck = ref<number | null>(null)
|
||||
const luckLoading = ref(false)
|
||||
|
||||
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
const permissions = skinSiteUser.value?.permissions ?? []
|
||||
return permissions.includes('WEB_ADMIN') || permissions.includes('SERVER_ADMIN')
|
||||
})
|
||||
const players = computed(() => skinSitePlayers.value)
|
||||
const playersLoading = computed(
|
||||
() => skinSitePlayersStatus.value === 'checking' || skinSitePlayersStatus.value === 'idle',
|
||||
)
|
||||
const playersError = computed(() => skinSitePlayersStatus.value === 'error')
|
||||
|
||||
async function loadLuck() {
|
||||
if (!signedIn.value) {
|
||||
luck.value = null
|
||||
return
|
||||
}
|
||||
luckLoading.value = true
|
||||
try {
|
||||
luck.value = await requestSkinSiteLuck()
|
||||
} catch {
|
||||
luck.value = null
|
||||
} finally {
|
||||
luckLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadLuck)
|
||||
watch(signedIn, loadLuck)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-4"
|
||||
>
|
||||
<div v-if="!signedIn" class="flex flex-col items-start gap-2">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.signedOutTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.signedOutBody) }}</p>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" @click="emit('sign-in')">
|
||||
{{ formatMessage(messages.signIn) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-highlight text-lg font-bold text-contrast"
|
||||
>
|
||||
{{ skinSiteUser!.username.slice(0, 1).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-semibold text-contrast">{{ skinSiteUser!.username }}</span>
|
||||
<span class="truncate text-xs text-secondary">{{ skinSiteUser!.uuid }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="emit('open-settings')"
|
||||
>
|
||||
{{ formatMessage(messages.settings) }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="emit('open-admin')"
|
||||
>
|
||||
{{ formatMessage(messages.admin) }}
|
||||
</button>
|
||||
<div
|
||||
class="ml-auto flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-sm"
|
||||
:title="formatMessage(messages.luck)"
|
||||
>
|
||||
<SpinnerIcon v-if="luckLoading" class="size-4 animate-spin" />
|
||||
<template v-else-if="luck !== null">
|
||||
<SparklesIcon class="size-4 text-brand" />
|
||||
<span class="font-semibold text-contrast">{{ luck }}</span>
|
||||
</template>
|
||||
<span v-else class="text-xs text-secondary">{{
|
||||
formatMessage(messages.luckUnavailable)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-contrast">{{
|
||||
formatMessage(messages.players)
|
||||
}}</span>
|
||||
|
||||
<div v-if="playersLoading" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<p v-else-if="playersError" class="m-0 text-sm text-red">
|
||||
{{ formatMessage(messages.error) }}
|
||||
</p>
|
||||
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.noPlayers) }}
|
||||
</p>
|
||||
<div v-else class="flex flex-wrap gap-3">
|
||||
<div
|
||||
v-for="player in players"
|
||||
:key="player.uuid"
|
||||
class="flex items-center gap-2 rounded-xl bg-button-bg px-2 py-1.5"
|
||||
>
|
||||
<Avatar
|
||||
:src="player.headDataUrl"
|
||||
:alt="player.name"
|
||||
size="2rem"
|
||||
pixelated
|
||||
class="!rounded-md"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-contrast">{{ player.name }}</span>
|
||||
<span v-if="player.isMojang" class="text-[10px] uppercase text-secondary"
|
||||
>Mojang</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { skinSiteUser } from '@/composables/skin-site-session'
|
||||
|
||||
import AdminAccessAuditPage from './admin/AdminAccessAuditPage.vue'
|
||||
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
|
||||
import AdminChatPage from './admin/AdminChatPage.vue'
|
||||
import AdminDonationsPage from './admin/AdminDonationsPage.vue'
|
||||
import AdminDownloadsPage from './admin/AdminDownloadsPage.vue'
|
||||
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
|
||||
import AdminModsPage from './admin/AdminModsPage.vue'
|
||||
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
|
||||
import AdminReviewPage from './admin/AdminReviewPage.vue'
|
||||
import AdminServersPage from './admin/AdminServersPage.vue'
|
||||
import AdminSummaryPage from './admin/AdminSummaryPage.vue'
|
||||
import AdminUsersPage from './admin/AdminUsersPage.vue'
|
||||
import AdminWikiPage from './admin/AdminWikiPage.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'back'): void }>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.admin.title', defaultMessage: 'Admin console' },
|
||||
back: { id: 'app.skin-site.admin.back', defaultMessage: 'Back to home' },
|
||||
noAccess: { id: 'app.skin-site.admin.no-access', defaultMessage: 'You do not have access.' },
|
||||
noAccessHint: {
|
||||
id: 'app.skin-site.admin.no-access-hint',
|
||||
defaultMessage: 'Sign in with an administrator account to use this console.',
|
||||
},
|
||||
groupSecurity: { id: 'app.skin-site.admin.group.security', defaultMessage: 'Security' },
|
||||
groupUsers: { id: 'app.skin-site.admin.group.users', defaultMessage: 'Users' },
|
||||
groupCommunity: { id: 'app.skin-site.admin.group.community', defaultMessage: 'Community' },
|
||||
groupResources: { id: 'app.skin-site.admin.group.resources', defaultMessage: 'Resources' },
|
||||
itemServers: { id: 'app.skin-site.admin.item.servers', defaultMessage: 'Servers' },
|
||||
itemSummary: { id: 'app.skin-site.admin.item.summary', defaultMessage: 'Summary' },
|
||||
itemAccessAudit: { id: 'app.skin-site.admin.item.access-audit', defaultMessage: 'Access audit' },
|
||||
itemUsers: { id: 'app.skin-site.admin.item.users', defaultMessage: 'Users' },
|
||||
itemPlayers: { id: 'app.skin-site.admin.item.players', defaultMessage: 'Players' },
|
||||
itemReview: { id: 'app.skin-site.admin.item.review', defaultMessage: 'Player review' },
|
||||
itemBroadcast: { id: 'app.skin-site.admin.item.broadcast', defaultMessage: 'Broadcast' },
|
||||
itemChat: { id: 'app.skin-site.admin.item.chat', defaultMessage: 'Chat rooms' },
|
||||
itemWiki: { id: 'app.skin-site.admin.item.wiki', defaultMessage: 'Wiki' },
|
||||
itemDonations: { id: 'app.skin-site.admin.item.donations', defaultMessage: 'Donations' },
|
||||
itemMods: { id: 'app.skin-site.admin.item.mods', defaultMessage: 'Mods' },
|
||||
itemLauncher: { id: 'app.skin-site.admin.item.launcher', defaultMessage: 'Launcher' },
|
||||
itemDownloads: { id: 'app.skin-site.admin.item.downloads', defaultMessage: 'Downloads' },
|
||||
})
|
||||
|
||||
type AdminKey =
|
||||
| 'servers'
|
||||
| 'summary'
|
||||
| 'access-audit'
|
||||
| 'users'
|
||||
| 'players'
|
||||
| 'review'
|
||||
| 'broadcast'
|
||||
| 'chat'
|
||||
| 'wiki'
|
||||
| 'donations'
|
||||
| 'mods'
|
||||
| 'launcher'
|
||||
| 'downloads'
|
||||
|
||||
interface NavItem {
|
||||
key: AdminKey
|
||||
label: string
|
||||
}
|
||||
interface NavGroup {
|
||||
id: string
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const navGroups = computed<NavGroup[]>(() => [
|
||||
{
|
||||
id: 'security',
|
||||
label: formatMessage(messages.groupSecurity),
|
||||
items: [
|
||||
...(hasWebAdmin.value
|
||||
? [{ key: 'servers' as AdminKey, label: formatMessage(messages.itemServers) }]
|
||||
: []),
|
||||
{ key: 'summary', label: formatMessage(messages.itemSummary) },
|
||||
{ key: 'access-audit', label: formatMessage(messages.itemAccessAudit) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: formatMessage(messages.groupUsers),
|
||||
items: [
|
||||
{ key: 'users', label: formatMessage(messages.itemUsers) },
|
||||
{ key: 'players', label: formatMessage(messages.itemPlayers) },
|
||||
{ key: 'review', label: formatMessage(messages.itemReview) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'community',
|
||||
label: formatMessage(messages.groupCommunity),
|
||||
items: [
|
||||
{ key: 'broadcast', label: formatMessage(messages.itemBroadcast) },
|
||||
{ key: 'chat', label: formatMessage(messages.itemChat) },
|
||||
{ key: 'wiki', label: formatMessage(messages.itemWiki) },
|
||||
...(hasWebAdmin.value
|
||||
? [{ key: 'donations' as AdminKey, label: formatMessage(messages.itemDonations) }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
label: formatMessage(messages.groupResources),
|
||||
items: [
|
||||
{ key: 'mods', label: formatMessage(messages.itemMods) },
|
||||
{ key: 'launcher', label: formatMessage(messages.itemLauncher) },
|
||||
{ key: 'downloads', label: formatMessage(messages.itemDownloads) },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const hasAdminAccess = computed(() => {
|
||||
const permissions = skinSiteUser.value?.permissions ?? []
|
||||
return permissions.includes('WEB_ADMIN') || permissions.includes('SERVER_ADMIN')
|
||||
})
|
||||
|
||||
const hasWebAdmin = computed(() => {
|
||||
const permissions = skinSiteUser.value?.permissions ?? []
|
||||
return permissions.includes('WEB_ADMIN')
|
||||
})
|
||||
|
||||
const current = ref<AdminKey>('summary')
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="emit('back')"
|
||||
>
|
||||
← {{ formatMessage(messages.back) }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="!hasAdminAccess"
|
||||
class="flex flex-col gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-6"
|
||||
>
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.noAccess) }}</h2>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.noAccessHint) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex min-h-0 flex-1 gap-4">
|
||||
<nav class="flex w-48 shrink-0 flex-col gap-3 overflow-y-auto">
|
||||
<div v-for="group in navGroups" :key="group.id" class="flex flex-col gap-1">
|
||||
<span class="px-2 text-[10px] font-bold tracking-wide text-secondary uppercase">
|
||||
{{ group.label }}
|
||||
</span>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="rounded-lg px-2 py-1.5 text-left text-xs font-semibold transition-colors"
|
||||
:class="
|
||||
current === item.key
|
||||
? 'bg-brand text-contrast'
|
||||
: 'text-secondary hover:bg-button-bg hover:text-contrast'
|
||||
"
|
||||
@click="current = item.key"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section
|
||||
class="min-w-0 flex-1 overflow-y-auto rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
|
||||
>
|
||||
<AdminSummaryPage v-if="current === 'summary'" />
|
||||
<AdminServersPage v-else-if="current === 'servers' && hasWebAdmin" />
|
||||
<AdminAccessAuditPage v-else-if="current === 'access-audit'" />
|
||||
<AdminBroadcastPage v-else-if="current === 'broadcast'" />
|
||||
<AdminChatPage v-else-if="current === 'chat'" />
|
||||
<AdminUsersPage v-else-if="current === 'users'" />
|
||||
<AdminPlayersPage v-else-if="current === 'players'" />
|
||||
<AdminReviewPage v-else-if="current === 'review'" />
|
||||
<AdminLauncherPage v-else-if="current === 'launcher'" />
|
||||
<AdminModsPage v-else-if="current === 'mods'" />
|
||||
<AdminWikiPage v-else-if="current === 'wiki'" />
|
||||
<AdminDownloadsPage v-else-if="current === 'downloads'" />
|
||||
<AdminDonationsPage v-else-if="current === 'donations' && hasWebAdmin" />
|
||||
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createRenderer, nextTick, ref } from 'vue'
|
||||
|
||||
const testSession = {
|
||||
skinSiteStatus: ref('checking'),
|
||||
skinSiteUser: ref<{ uuid: string } | null>(null),
|
||||
}
|
||||
const testGlobal = globalThis as unknown as {
|
||||
skinSiteAnnouncementSession: typeof testSession
|
||||
skinSiteAnnouncementFetch: () => Promise<unknown>
|
||||
}
|
||||
testGlobal.skinSiteAnnouncementSession = testSession
|
||||
const require = createRequire(import.meta.url)
|
||||
const { parse, compileScript } = require('vue/compiler-sfc')
|
||||
const componentUrl = new URL('./SkinSiteAnnouncements.vue', import.meta.url).href
|
||||
const stubs: Record<string, string> = {
|
||||
'@modrinth/assets': 'export const NewspaperIcon={}, SpinnerIcon={}, StarIcon={}',
|
||||
'@modrinth/ui':
|
||||
'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
|
||||
'@/helpers/skin-site-api':
|
||||
'export const skinSiteAnnouncements=()=>globalThis.skinSiteAnnouncementFetch()',
|
||||
'@/composables/skin-site-session':
|
||||
'export const {skinSiteStatus,skinSiteUser}=globalThis.skinSiteAnnouncementSession; export const reloadSkinSiteSession=async()=>{}',
|
||||
}
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier in stubs)
|
||||
return {
|
||||
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
|
||||
shortCircuit: true,
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
load(url, context, nextLoad) {
|
||||
if (url !== componentUrl) return nextLoad(url, context)
|
||||
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
|
||||
return {
|
||||
format: 'module',
|
||||
source: stripTypeScriptTypes(
|
||||
compileScript(descriptor, { id: 'skin-announcement-test' }).content,
|
||||
),
|
||||
shortCircuit: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
const { default: Announcements } = await import(componentUrl)
|
||||
hooks.deregister()
|
||||
Announcements.render = () => null
|
||||
|
||||
type Node = Record<string, unknown>
|
||||
const renderer = createRenderer<Node, Node>({
|
||||
createElement: () => ({}),
|
||||
createText: () => ({}),
|
||||
createComment: () => ({}),
|
||||
insert() {},
|
||||
remove() {},
|
||||
setText() {},
|
||||
setElementText() {},
|
||||
patchProp() {},
|
||||
parentNode: () => null,
|
||||
nextSibling: () => null,
|
||||
})
|
||||
const flush = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
await nextTick()
|
||||
}
|
||||
type State = {
|
||||
announcements: Array<{ id: number }>
|
||||
loading: boolean
|
||||
failed: boolean
|
||||
load: () => Promise<void>
|
||||
}
|
||||
let requests = 0
|
||||
function mount(t: test.TestContext) {
|
||||
const app = renderer.createApp(Announcements)
|
||||
const instance = app.mount({})
|
||||
t.after(() => app.unmount())
|
||||
return instance.$.setupState as State
|
||||
}
|
||||
test.beforeEach(() => {
|
||||
testSession.skinSiteStatus.value = 'checking'
|
||||
testSession.skinSiteUser.value = null
|
||||
requests = 0
|
||||
testGlobal.skinSiteAnnouncementFetch = async () => {
|
||||
requests++
|
||||
return { success: true, payload: [{ id: 1, title: '公告', updateAt: 1, star: false }] }
|
||||
}
|
||||
})
|
||||
|
||||
test('announcements wait for startup renewal and reload when sign-in finishes', async (t) => {
|
||||
const state = mount(t)
|
||||
await flush()
|
||||
assert.equal(requests, 0)
|
||||
assert.equal(state.loading, true)
|
||||
testSession.skinSiteUser.value = { uuid: 'owner' }
|
||||
testSession.skinSiteStatus.value = 'signed-in'
|
||||
await flush()
|
||||
assert.equal(requests, 1)
|
||||
assert.equal(state.announcements.length, 1)
|
||||
})
|
||||
|
||||
test('signed-out users do not request protected announcements or see a load failure', async (t) => {
|
||||
testSession.skinSiteStatus.value = 'signed-out'
|
||||
const state = mount(t)
|
||||
await flush()
|
||||
assert.equal(requests, 0)
|
||||
assert.equal(state.loading, false)
|
||||
assert.equal(state.failed, false)
|
||||
})
|
||||
|
||||
test('signing out clears announcements and ignores an old in-flight response', async (t) => {
|
||||
testSession.skinSiteStatus.value = 'signed-in'
|
||||
let finish!: (value: unknown) => void
|
||||
testGlobal.skinSiteAnnouncementFetch = () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const state = mount(t)
|
||||
await flush()
|
||||
testSession.skinSiteStatus.value = 'signed-out'
|
||||
await flush()
|
||||
finish({ success: true, payload: [{ id: 2 }] })
|
||||
await flush()
|
||||
assert.deepEqual(state.announcements, [])
|
||||
assert.equal(state.failed, false)
|
||||
})
|
||||
|
||||
test('a failed announcement request can be retried', async (t) => {
|
||||
testSession.skinSiteStatus.value = 'signed-in'
|
||||
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: false })
|
||||
const state = mount(t)
|
||||
await flush()
|
||||
assert.equal(state.failed, true)
|
||||
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: true, payload: [{ id: 3 }] })
|
||||
await state.load()
|
||||
assert.equal(state.failed, false)
|
||||
assert.equal(state.announcements[0].id, 3)
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { NewspaperIcon, SpinnerIcon, StarIcon } from '@modrinth/assets'
|
||||
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
reloadSkinSiteSession,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
} from '@/composables/skin-site-session'
|
||||
import { type SkinSiteAnnouncement, skinSiteAnnouncements } from '@/helpers/skin-site-api'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.home.announcements.title', defaultMessage: 'Announcements' },
|
||||
empty: {
|
||||
id: 'app.skin-site.home.announcements.empty',
|
||||
defaultMessage: 'No announcements right now.',
|
||||
},
|
||||
loading: { id: 'app.skin-site.home.announcements.loading', defaultMessage: 'Loading…' },
|
||||
failed: {
|
||||
id: 'app.skin-site.home.announcements.failed',
|
||||
defaultMessage: 'Could not load announcements.',
|
||||
},
|
||||
signedOut: {
|
||||
id: 'app.skin-site.home.announcements.signed-out',
|
||||
defaultMessage: 'Sign in to view announcements.',
|
||||
},
|
||||
})
|
||||
|
||||
const announcements = ref<SkinSiteAnnouncement[]>([])
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
let requestId = 0
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleDateString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const currentRequest = ++requestId
|
||||
announcements.value = []
|
||||
failed.value = skinSiteStatus.value === 'error'
|
||||
loading.value = skinSiteStatus.value === 'checking'
|
||||
if (skinSiteStatus.value !== 'signed-in') return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await skinSiteAnnouncements()
|
||||
if (currentRequest !== requestId) return
|
||||
if (result.success) {
|
||||
announcements.value = [...(result.payload ?? [])]
|
||||
.sort((a, b) => {
|
||||
if (a.star !== b.star) return a.star ? -1 : 1
|
||||
return b.updateAt - a.updateAt
|
||||
})
|
||||
.slice(0, 5)
|
||||
} else {
|
||||
failed.value = true
|
||||
}
|
||||
} catch {
|
||||
if (currentRequest === requestId) failed.value = true
|
||||
} finally {
|
||||
if (currentRequest === requestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
if (skinSiteStatus.value === 'error') await reloadSkinSiteSession()
|
||||
else await load()
|
||||
}
|
||||
|
||||
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], load, { immediate: true })
|
||||
onUnmounted(() => {
|
||||
requestId++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="announcements">
|
||||
<header class="flex items-center gap-2 border-0 border-b border-solid border-surface-5 pb-4">
|
||||
<NewspaperIcon class="size-5 text-brand" />
|
||||
<h2 class="m-0 text-base font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||
<span
|
||||
v-if="announcements.length"
|
||||
class="ml-auto rounded-full bg-button-bg px-2 py-0.5 text-xs text-secondary"
|
||||
>{{ announcements.length }}</span
|
||||
>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="announcement-feedback" role="status">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<div v-else-if="failed" class="announcement-feedback">
|
||||
<p class="m-0">{{ formatMessage(messages.failed) }}</p>
|
||||
<button type="button" class="text-brand hover:underline" @click="retry">
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="skinSiteStatus === 'signed-out'" class="announcement-feedback">
|
||||
{{ formatMessage(messages.signedOut) }}
|
||||
</p>
|
||||
<p v-else-if="!announcements.length" class="announcement-feedback">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<div v-else class="flex flex-col">
|
||||
<article v-for="item in announcements" :key="item.id" class="announcement-entry">
|
||||
<div class="flex items-start gap-2">
|
||||
<StarIcon v-if="item.star" class="mt-0.5 size-4 shrink-0 text-brand" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
|
||||
</div>
|
||||
<p v-if="item.subtitle" class="m-0 text-sm leading-relaxed text-secondary">
|
||||
{{ item.subtitle }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.announcements {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-raised-bg);
|
||||
}
|
||||
.announcement-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 1.25rem 0;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
}
|
||||
.announcement-entry:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.announcement-feedback {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin: 0;
|
||||
padding: 1.5rem 0 0.5rem;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { type SkinSiteBugIssueType, skinSiteCreateBugIssue } from '@/helpers/skin-site-api'
|
||||
|
||||
const emit = defineEmits<{ (e: 'created', id: number): void }>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError, addNotification } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.bug-create.title', defaultMessage: 'New issue' },
|
||||
type: { id: 'app.skin-site.bug-create.type', defaultMessage: 'Type' },
|
||||
titleField: { id: 'app.skin-site.bug-create.title-field', defaultMessage: 'Title' },
|
||||
titlePlaceholder: {
|
||||
id: 'app.skin-site.bug-create.title-placeholder',
|
||||
defaultMessage: 'A short summary of the problem',
|
||||
},
|
||||
description: { id: 'app.skin-site.bug-create.description', defaultMessage: 'Description' },
|
||||
descriptionPlaceholder: {
|
||||
id: 'app.skin-site.bug-create.description-placeholder',
|
||||
defaultMessage: 'What happened, and how can it be reproduced?',
|
||||
},
|
||||
privateLabel: { id: 'app.skin-site.bug-create.private', defaultMessage: 'Private issue' },
|
||||
privateHint: {
|
||||
id: 'app.skin-site.bug-create.private-hint',
|
||||
defaultMessage: 'Only you and the maintainers can see it.',
|
||||
},
|
||||
images: { id: 'app.skin-site.bug-create.images', defaultMessage: 'Images' },
|
||||
imagesHint: {
|
||||
id: 'app.skin-site.bug-create.images-hint',
|
||||
defaultMessage: 'Optional, up to 5 images.',
|
||||
},
|
||||
submit: { id: 'app.skin-site.bug-create.submit', defaultMessage: 'Create issue' },
|
||||
submitting: { id: 'app.skin-site.bug-create.submitting', defaultMessage: 'Creating…' },
|
||||
created: { id: 'app.skin-site.bug-create.created', defaultMessage: 'Issue created.' },
|
||||
cancel: { id: 'app.skin-site.bug-create.cancel', defaultMessage: 'Cancel' },
|
||||
typeBug: { id: 'app.skin-site.bugs.type.bug', defaultMessage: 'Bug' },
|
||||
typeFeature: { id: 'app.skin-site.bugs.type.feature', defaultMessage: 'Feature' },
|
||||
typeImprovement: { id: 'app.skin-site.bugs.type.improvement', defaultMessage: 'Improvement' },
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const type = ref<SkinSiteBugIssueType>('BUG')
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
const isPrivate = ref(false)
|
||||
const images = ref<File[]>([])
|
||||
const working = ref(false)
|
||||
|
||||
const TYPE_OPTIONS: SkinSiteBugIssueType[] = ['BUG', 'FEATURE', 'IMPROVEMENT']
|
||||
|
||||
function typeLabel(t: SkinSiteBugIssueType): string {
|
||||
if (t === 'FEATURE') return formatMessage(messages.typeFeature)
|
||||
if (t === 'IMPROVEMENT') return formatMessage(messages.typeImprovement)
|
||||
return formatMessage(messages.typeBug)
|
||||
}
|
||||
|
||||
const canSubmit = computed(
|
||||
() => title.value.trim().length > 0 && description.value.trim().length > 0 && !working.value,
|
||||
)
|
||||
|
||||
function reset() {
|
||||
type.value = 'BUG'
|
||||
title.value = ''
|
||||
description.value = ''
|
||||
isPrivate.value = false
|
||||
images.value = []
|
||||
working.value = false
|
||||
}
|
||||
|
||||
function show() {
|
||||
reset()
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function onFiles(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
images.value = Array.from(input.files ?? []).slice(0, 5)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value) return
|
||||
working.value = true
|
||||
try {
|
||||
const result = await skinSiteCreateBugIssue(
|
||||
{
|
||||
type: type.value,
|
||||
title: title.value.trim(),
|
||||
description: description.value.trim(),
|
||||
private: isPrivate.value,
|
||||
},
|
||||
images.value,
|
||||
)
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
addNotification({ type: 'success', title: formatMessage(messages.created) })
|
||||
modal.value?.hide()
|
||||
emit('created', result.payload.id)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
modal.value?.hide()
|
||||
reset()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.type) }}</span>
|
||||
<select
|
||||
v-model="type"
|
||||
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast"
|
||||
>
|
||||
<option v-for="t in TYPE_OPTIONS" :key="t" :value="t">{{ typeLabel(t) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.titleField) }}</span>
|
||||
<StyledInput
|
||||
v-model="title"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.titlePlaceholder)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.description) }}</span>
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="5"
|
||||
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-2">
|
||||
<input v-model="isPrivate" type="checkbox" class="mt-1" />
|
||||
<span class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(messages.privateLabel) }}
|
||||
</span>
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.privateHint) }}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.images) }}</span>
|
||||
<input type="file" accept="image/*" multiple @change="onFiles" />
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.imagesHint) }}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
>
|
||||
{{ working ? formatMessage(messages.submitting) : formatMessage(messages.submit) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,463 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type SkinSiteBugComment,
|
||||
skinSiteBugComments,
|
||||
skinSiteBugImageFetch,
|
||||
type SkinSiteBugIssue,
|
||||
skinSiteBugIssue,
|
||||
type SkinSiteBugIssueType,
|
||||
type SkinSiteBugPriority,
|
||||
type SkinSiteBugResolution,
|
||||
type SkinSiteBugStatus,
|
||||
skinSiteCreateBugComment,
|
||||
skinSiteDeleteBugComment,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
const props = defineProps<{ issueId: number }>()
|
||||
const emit = defineEmits<{ (e: 'back'): void }>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError, addNotification } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
back: { id: 'app.skin-site.bug-detail.back', defaultMessage: 'Back to list' },
|
||||
loading: { id: 'app.skin-site.bug-detail.loading', defaultMessage: 'Loading…' },
|
||||
failed: { id: 'app.skin-site.bug-detail.failed', defaultMessage: 'Could not load this issue.' },
|
||||
issueNo: { id: 'app.skin-site.bug-detail.issue-no', defaultMessage: 'Issue #{id}' },
|
||||
by: { id: 'app.skin-site.bug-detail.by', defaultMessage: 'by {name}' },
|
||||
assignee: { id: 'app.skin-site.bug-detail.assignee', defaultMessage: 'Assignee: {name}' },
|
||||
unassigned: { id: 'app.skin-site.bug-detail.unassigned', defaultMessage: 'Unassigned' },
|
||||
descriptionEmpty: {
|
||||
id: 'app.skin-site.bug-detail.description-empty',
|
||||
defaultMessage: 'No description provided.',
|
||||
},
|
||||
resolution: { id: 'app.skin-site.bug-detail.resolution', defaultMessage: 'Resolution: {name}' },
|
||||
commentsTitle: { id: 'app.skin-site.bug-detail.comments-title', defaultMessage: 'Comments' },
|
||||
commentsEmpty: {
|
||||
id: 'app.skin-site.bug-detail.comments-empty',
|
||||
defaultMessage: 'No comments yet. Be the first to reply.',
|
||||
},
|
||||
commentsLoading: {
|
||||
id: 'app.skin-site.bug-detail.comments-loading',
|
||||
defaultMessage: 'Loading comments…',
|
||||
},
|
||||
commentPlaceholder: {
|
||||
id: 'app.skin-site.bug-detail.comment-placeholder',
|
||||
defaultMessage: 'Write a comment…',
|
||||
},
|
||||
commentSubmit: { id: 'app.skin-site.bug-detail.comment-submit', defaultMessage: 'Comment' },
|
||||
commentSending: { id: 'app.skin-site.bug-detail.comment-sending', defaultMessage: 'Sending…' },
|
||||
commentEmptyError: {
|
||||
id: 'app.skin-site.bug-detail.comment-empty-error',
|
||||
defaultMessage: 'Comment cannot be empty.',
|
||||
},
|
||||
commentPosted: {
|
||||
id: 'app.skin-site.bug-detail.comment-posted',
|
||||
defaultMessage: 'Comment posted.',
|
||||
},
|
||||
commentDeleted: {
|
||||
id: 'app.skin-site.bug-detail.comment-deleted',
|
||||
defaultMessage: 'Comment deleted.',
|
||||
},
|
||||
delete: { id: 'app.skin-site.bug-detail.delete', defaultMessage: 'Delete' },
|
||||
deleting: { id: 'app.skin-site.bug-detail.deleting', defaultMessage: 'Deleting…' },
|
||||
system: { id: 'app.skin-site.bug-detail.system', defaultMessage: 'System' },
|
||||
typeBug: { id: 'app.skin-site.bugs.type.bug', defaultMessage: 'Bug' },
|
||||
typeFeature: { id: 'app.skin-site.bugs.type.feature', defaultMessage: 'Feature' },
|
||||
typeImprovement: { id: 'app.skin-site.bugs.type.improvement', defaultMessage: 'Improvement' },
|
||||
statusPending: { id: 'app.skin-site.bugs.status.pending', defaultMessage: 'Pending' },
|
||||
statusConfirmed: { id: 'app.skin-site.bugs.status.confirmed', defaultMessage: 'Confirmed' },
|
||||
statusNeedsInfo: { id: 'app.skin-site.bugs.status.needs-info', defaultMessage: 'Needs info' },
|
||||
statusInProgress: { id: 'app.skin-site.bugs.status.in-progress', defaultMessage: 'In progress' },
|
||||
statusVerifying: { id: 'app.skin-site.bugs.status.verifying', defaultMessage: 'Verifying' },
|
||||
statusClosed: { id: 'app.skin-site.bugs.status.closed', defaultMessage: 'Closed' },
|
||||
priorityCritical: { id: 'app.skin-site.bugs.priority.critical', defaultMessage: 'Critical' },
|
||||
priorityHigh: { id: 'app.skin-site.bugs.priority.high', defaultMessage: 'High' },
|
||||
priorityNormal: { id: 'app.skin-site.bugs.priority.normal', defaultMessage: 'Normal' },
|
||||
priorityLow: { id: 'app.skin-site.bugs.priority.low', defaultMessage: 'Low' },
|
||||
resFixed: { id: 'app.skin-site.bugs.resolution.fixed', defaultMessage: 'Fixed' },
|
||||
resWontFix: { id: 'app.skin-site.bugs.resolution.wont-fix', defaultMessage: 'Won’t fix' },
|
||||
resByDesign: { id: 'app.skin-site.bugs.resolution.by-design', defaultMessage: 'By design' },
|
||||
resDuplicate: { id: 'app.skin-site.bugs.resolution.duplicate', defaultMessage: 'Duplicate' },
|
||||
resCannotReproduce: {
|
||||
id: 'app.skin-site.bugs.resolution.cannot-reproduce',
|
||||
defaultMessage: 'Cannot reproduce',
|
||||
},
|
||||
resInvalid: { id: 'app.skin-site.bugs.resolution.invalid', defaultMessage: 'Invalid' },
|
||||
})
|
||||
|
||||
const issue = ref<SkinSiteBugIssue | null>(null)
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
|
||||
const comments = ref<SkinSiteBugComment[]>([])
|
||||
const commentsLoading = ref(true)
|
||||
const commentsHasMore = ref(false)
|
||||
const commentsLoadingMore = ref(false)
|
||||
const commentDraft = ref('')
|
||||
const commentSending = ref(false)
|
||||
const deletingId = ref<number | null>(null)
|
||||
|
||||
function typeLabel(type: SkinSiteBugIssueType): string {
|
||||
switch (type) {
|
||||
case 'FEATURE':
|
||||
return formatMessage(messages.typeFeature)
|
||||
case 'IMPROVEMENT':
|
||||
return formatMessage(messages.typeImprovement)
|
||||
default:
|
||||
return formatMessage(messages.typeBug)
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: SkinSiteBugStatus): string {
|
||||
switch (status) {
|
||||
case 'CONFIRMED':
|
||||
return formatMessage(messages.statusConfirmed)
|
||||
case 'NEEDS_INFO':
|
||||
return formatMessage(messages.statusNeedsInfo)
|
||||
case 'IN_PROGRESS':
|
||||
return formatMessage(messages.statusInProgress)
|
||||
case 'VERIFYING':
|
||||
return formatMessage(messages.statusVerifying)
|
||||
case 'CLOSED':
|
||||
return formatMessage(messages.statusClosed)
|
||||
default:
|
||||
return formatMessage(messages.statusPending)
|
||||
}
|
||||
}
|
||||
|
||||
function priorityLabel(priority: SkinSiteBugPriority): string {
|
||||
switch (priority) {
|
||||
case 'CRITICAL':
|
||||
return formatMessage(messages.priorityCritical)
|
||||
case 'HIGH':
|
||||
return formatMessage(messages.priorityHigh)
|
||||
case 'LOW':
|
||||
return formatMessage(messages.priorityLow)
|
||||
default:
|
||||
return formatMessage(messages.priorityNormal)
|
||||
}
|
||||
}
|
||||
|
||||
function resolutionLabel(resolution: SkinSiteBugResolution): string {
|
||||
switch (resolution) {
|
||||
case 'WONT_FIX':
|
||||
return formatMessage(messages.resWontFix)
|
||||
case 'BY_DESIGN':
|
||||
return formatMessage(messages.resByDesign)
|
||||
case 'DUPLICATE':
|
||||
return formatMessage(messages.resDuplicate)
|
||||
case 'CANNOT_REPRODUCE':
|
||||
return formatMessage(messages.resCannotReproduce)
|
||||
case 'INVALID':
|
||||
return formatMessage(messages.resInvalid)
|
||||
default:
|
||||
return formatMessage(messages.resFixed)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(ts: number): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => !commentSending.value && commentDraft.value.trim().length > 0)
|
||||
|
||||
const imageUrls = ref<Record<string, string>>({})
|
||||
|
||||
function revokeImageUrls() {
|
||||
for (const url of Object.values(imageUrls.value)) URL.revokeObjectURL(url)
|
||||
imageUrls.value = {}
|
||||
}
|
||||
|
||||
async function loadIssueImages(issue: SkinSiteBugIssue) {
|
||||
revokeImageUrls()
|
||||
const entries = await Promise.all(
|
||||
(issue.images ?? []).map(async (image) => {
|
||||
const blob = await skinSiteBugImageFetch(image.hash)
|
||||
return blob ? [image.hash, URL.createObjectURL(blob)] : null
|
||||
}),
|
||||
)
|
||||
const map: Record<string, string> = {}
|
||||
for (const entry of entries) if (entry) map[entry[0]] = entry[1]
|
||||
imageUrls.value = map
|
||||
}
|
||||
|
||||
async function loadIssue() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const result = await skinSiteBugIssue(props.issueId)
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
issue.value = result.payload
|
||||
void loadIssueImages(result.payload)
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadComments(reset = true) {
|
||||
if (reset) {
|
||||
commentsLoading.value = true
|
||||
comments.value = []
|
||||
} else {
|
||||
commentsLoadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const result = await skinSiteBugComments(props.issueId, reset ? 0 : comments.value.length, 50)
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
const page = result.payload
|
||||
comments.value = reset ? page.items : [...comments.value, ...page.items]
|
||||
commentsHasMore.value = page.hasMore
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
commentsLoading.value = false
|
||||
commentsLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
const content = commentDraft.value.trim()
|
||||
if (!content || commentSending.value) return
|
||||
commentSending.value = true
|
||||
try {
|
||||
const result = await skinSiteCreateBugComment(props.issueId, content)
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
comments.value = [...comments.value, result.payload]
|
||||
commentDraft.value = ''
|
||||
addNotification({ type: 'success', title: formatMessage(messages.commentPosted) })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
commentSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(comment: SkinSiteBugComment) {
|
||||
if (deletingId.value !== null) return
|
||||
deletingId.value = comment.id
|
||||
try {
|
||||
const result = await skinSiteDeleteBugComment(comment.id)
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
comments.value = comments.value.filter((c) => c.id !== comment.id)
|
||||
addNotification({ type: 'success', title: formatMessage(messages.commentDeleted) })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadIssue()
|
||||
void loadComments(true)
|
||||
})
|
||||
|
||||
onUnmounted(revokeImageUrls)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="emit('back')"
|
||||
>
|
||||
← {{ formatMessage(messages.back) }}
|
||||
</button>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<p v-else-if="failed" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="issue">
|
||||
<header
|
||||
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
|
||||
>
|
||||
{{ typeLabel(issue.type) }}
|
||||
</span>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="{
|
||||
'bg-green text-contrast':
|
||||
issue.status === 'CONFIRMED' || issue.status === 'VERIFYING',
|
||||
'bg-yellow text-contrast':
|
||||
issue.status === 'NEEDS_INFO' || issue.status === 'IN_PROGRESS',
|
||||
'bg-button-bg text-secondary': issue.status === 'PENDING',
|
||||
'bg-surface-5 text-secondary': issue.status === 'CLOSED',
|
||||
}"
|
||||
>
|
||||
{{ statusLabel(issue.status) }}
|
||||
</span>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="{
|
||||
'bg-red text-contrast': issue.priority === 'CRITICAL',
|
||||
'bg-orange text-contrast': issue.priority === 'HIGH',
|
||||
'bg-button-bg text-secondary':
|
||||
issue.priority === 'NORMAL' || issue.priority === 'LOW',
|
||||
}"
|
||||
>
|
||||
{{ priorityLabel(issue.priority) }}
|
||||
</span>
|
||||
<span v-if="issue.private" class="shrink-0 text-[10px] font-semibold text-secondary">
|
||||
🔒
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-xs text-secondary">
|
||||
{{ formatMessage(messages.issueNo, { id: issue.id }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="m-0 text-xl font-bold text-contrast">{{ issue.title }}</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
|
||||
<span>{{ formatMessage(messages.by, { name: issue.authorName }) }}</span>
|
||||
<span>{{ formatDateTime(issue.createdAt) }}</span>
|
||||
<span v-if="issue.assigneeName">
|
||||
{{ formatMessage(messages.assignee, { name: issue.assigneeName }) }}
|
||||
</span>
|
||||
<span v-else>{{ formatMessage(messages.unassigned) }}</span>
|
||||
</div>
|
||||
<span v-if="issue.resolution" class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.resolution, { name: resolutionLabel(issue.resolution) }) }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="rounded-xl border border-solid border-surface-5 bg-bg-raised p-4">
|
||||
<p
|
||||
v-if="issue.description"
|
||||
class="m-0 whitespace-pre-wrap break-words text-sm text-contrast"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</p>
|
||||
<p v-else class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.descriptionEmpty) }}
|
||||
</p>
|
||||
<div v-if="issue.images.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<a
|
||||
v-for="image in issue.images"
|
||||
:key="image.hash"
|
||||
:href="imageUrls[image.hash]"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block overflow-hidden rounded-lg border border-solid border-surface-5"
|
||||
>
|
||||
<img
|
||||
v-if="imageUrls[image.hash]"
|
||||
:src="imageUrls[image.hash]"
|
||||
alt="attachment"
|
||||
class="max-h-48 max-w-full object-contain"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="block bg-surface-5 px-2 py-1 text-[10px] font-mono text-secondary"
|
||||
>
|
||||
{{ image.hash }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-base font-bold text-contrast">
|
||||
{{ formatMessage(messages.commentsTitle) }}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="commentsLoading" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
{{ formatMessage(messages.commentsLoading) }}
|
||||
</div>
|
||||
<p
|
||||
v-else-if="!comments.length"
|
||||
class="m-0 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.commentsEmpty) }}
|
||||
</p>
|
||||
<div
|
||||
v-for="comment in comments"
|
||||
v-else
|
||||
:key="comment.id"
|
||||
class="flex flex-col gap-1.5 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
|
||||
<span class="font-semibold text-contrast">{{ comment.authorName }}</span>
|
||||
<span
|
||||
v-if="comment.system"
|
||||
class="rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold"
|
||||
>
|
||||
{{ formatMessage(messages.system) }}
|
||||
</span>
|
||||
<span>{{ formatDateTime(comment.createdAt) }}</span>
|
||||
<span v-if="comment.editedAt">(edited)</span>
|
||||
<button
|
||||
v-if="comment.canDelete"
|
||||
type="button"
|
||||
class="ml-auto rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="deletingId !== null"
|
||||
@click="deleteComment(comment)"
|
||||
>
|
||||
{{
|
||||
deletingId === comment.id
|
||||
? formatMessage(messages.deleting)
|
||||
: formatMessage(messages.delete)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p class="m-0 whitespace-pre-wrap break-words text-sm text-contrast">
|
||||
{{ comment.content }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="commentsHasMore"
|
||||
type="button"
|
||||
class="w-fit self-center rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="commentsLoadingMore"
|
||||
@click="loadComments(false)"
|
||||
>
|
||||
{{ formatMessage(messages.commentsTitle) }}
|
||||
</button>
|
||||
|
||||
<form class="flex flex-col gap-2" @submit.prevent="submitComment">
|
||||
<textarea
|
||||
v-model="commentDraft"
|
||||
rows="3"
|
||||
class="w-full resize-y rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 text-sm text-contrast outline-none focus:border-brand"
|
||||
:placeholder="formatMessage(messages.commentPlaceholder)"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-fit self-end rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
{{
|
||||
commentSending
|
||||
? formatMessage(messages.commentSending)
|
||||
: formatMessage(messages.commentSubmit)
|
||||
}}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type SkinSiteBugIssue,
|
||||
skinSiteBugIssues,
|
||||
type SkinSiteBugIssueType,
|
||||
type SkinSiteBugPriority,
|
||||
type SkinSiteBugStatus,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.bugs.title', defaultMessage: 'Bugs & suggestions' },
|
||||
back: { id: 'app.skin-site.bugs.back', defaultMessage: 'Back to home' },
|
||||
subtitle: {
|
||||
id: 'app.skin-site.bugs.subtitle',
|
||||
defaultMessage: 'Report issues and follow their progress.',
|
||||
},
|
||||
loading: { id: 'app.skin-site.bugs.loading', defaultMessage: 'Loading…' },
|
||||
failed: { id: 'app.skin-site.bugs.failed', defaultMessage: 'Could not load issues.' },
|
||||
empty: { id: 'app.skin-site.bugs.empty', defaultMessage: 'No issues match these filters.' },
|
||||
all: { id: 'app.skin-site.bugs.all', defaultMessage: 'All' },
|
||||
more: { id: 'app.skin-site.bugs.more', defaultMessage: 'Load more' },
|
||||
openOnly: { id: 'app.skin-site.bugs.open-only', defaultMessage: 'Open only' },
|
||||
create: { id: 'app.skin-site.bugs.create', defaultMessage: 'New issue' },
|
||||
notifications: {
|
||||
id: 'app.skin-site.bugs.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
comments: { id: 'app.skin-site.bugs.comments', defaultMessage: '{count} comments' },
|
||||
typeBug: { id: 'app.skin-site.bugs.type.bug', defaultMessage: 'Bug' },
|
||||
typeFeature: { id: 'app.skin-site.bugs.type.feature', defaultMessage: 'Feature' },
|
||||
typeImprovement: { id: 'app.skin-site.bugs.type.improvement', defaultMessage: 'Improvement' },
|
||||
statusPending: { id: 'app.skin-site.bugs.status.pending', defaultMessage: 'Pending' },
|
||||
statusConfirmed: { id: 'app.skin-site.bugs.status.confirmed', defaultMessage: 'Confirmed' },
|
||||
statusNeedsInfo: { id: 'app.skin-site.bugs.status.needs-info', defaultMessage: 'Needs info' },
|
||||
statusInProgress: { id: 'app.skin-site.bugs.status.in-progress', defaultMessage: 'In progress' },
|
||||
statusVerifying: { id: 'app.skin-site.bugs.status.verifying', defaultMessage: 'Verifying' },
|
||||
statusClosed: { id: 'app.skin-site.bugs.status.closed', defaultMessage: 'Closed' },
|
||||
priorityCritical: { id: 'app.skin-site.bugs.priority.critical', defaultMessage: 'Critical' },
|
||||
priorityHigh: { id: 'app.skin-site.bugs.priority.high', defaultMessage: 'High' },
|
||||
priorityNormal: { id: 'app.skin-site.bugs.priority.normal', defaultMessage: 'Normal' },
|
||||
priorityLow: { id: 'app.skin-site.bugs.priority.low', defaultMessage: 'Low' },
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'back' | 'create' | 'notifications'): void
|
||||
(e: 'open', id: number): void
|
||||
}>()
|
||||
|
||||
const items = ref<SkinSiteBugIssue[]>([])
|
||||
const hasMore = ref(false)
|
||||
const loading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
const failed = ref(false)
|
||||
|
||||
const typeFilter = ref<SkinSiteBugIssueType | ''>('')
|
||||
const statusFilter = ref<SkinSiteBugStatus | ''>('')
|
||||
const priorityFilter = ref<SkinSiteBugPriority | ''>('')
|
||||
const openOnly = ref(true)
|
||||
|
||||
const TYPE_OPTIONS: SkinSiteBugIssueType[] = ['BUG', 'FEATURE', 'IMPROVEMENT']
|
||||
const STATUS_OPTIONS: SkinSiteBugStatus[] = [
|
||||
'PENDING',
|
||||
'CONFIRMED',
|
||||
'NEEDS_INFO',
|
||||
'IN_PROGRESS',
|
||||
'VERIFYING',
|
||||
'CLOSED',
|
||||
]
|
||||
const PRIORITY_OPTIONS: SkinSiteBugPriority[] = ['CRITICAL', 'HIGH', 'NORMAL', 'LOW']
|
||||
|
||||
function typeLabel(type: SkinSiteBugIssueType): string {
|
||||
switch (type) {
|
||||
case 'FEATURE':
|
||||
return formatMessage(messages.typeFeature)
|
||||
case 'IMPROVEMENT':
|
||||
return formatMessage(messages.typeImprovement)
|
||||
default:
|
||||
return formatMessage(messages.typeBug)
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: SkinSiteBugStatus): string {
|
||||
switch (status) {
|
||||
case 'CONFIRMED':
|
||||
return formatMessage(messages.statusConfirmed)
|
||||
case 'NEEDS_INFO':
|
||||
return formatMessage(messages.statusNeedsInfo)
|
||||
case 'IN_PROGRESS':
|
||||
return formatMessage(messages.statusInProgress)
|
||||
case 'VERIFYING':
|
||||
return formatMessage(messages.statusVerifying)
|
||||
case 'CLOSED':
|
||||
return formatMessage(messages.statusClosed)
|
||||
default:
|
||||
return formatMessage(messages.statusPending)
|
||||
}
|
||||
}
|
||||
|
||||
function priorityLabel(priority: SkinSiteBugPriority): string {
|
||||
switch (priority) {
|
||||
case 'CRITICAL':
|
||||
return formatMessage(messages.priorityCritical)
|
||||
case 'HIGH':
|
||||
return formatMessage(messages.priorityHigh)
|
||||
case 'LOW':
|
||||
return formatMessage(messages.priorityLow)
|
||||
default:
|
||||
return formatMessage(messages.priorityNormal)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleDateString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function load(reset = true) {
|
||||
if (reset) {
|
||||
loading.value = true
|
||||
items.value = []
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
failed.value = false
|
||||
try {
|
||||
const result = await skinSiteBugIssues({
|
||||
offset: reset ? 0 : items.value.length,
|
||||
limit: 20,
|
||||
type: typeFilter.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
priority: priorityFilter.value || undefined,
|
||||
openOnly: openOnly.value,
|
||||
})
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
const page = result.payload
|
||||
items.value = reset ? page.items : [...items.value, ...page.items]
|
||||
hasMore.value = page.hasMore
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
typeFilter.value = ''
|
||||
statusFilter.value = ''
|
||||
priorityFilter.value = ''
|
||||
void load(true)
|
||||
}
|
||||
|
||||
onMounted(() => load(true))
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="emit('back')"
|
||||
>
|
||||
← {{ formatMessage(messages.back) }}
|
||||
</button>
|
||||
|
||||
<header class="flex flex-wrap items-start gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
|
||||
@click="emit('notifications')"
|
||||
>
|
||||
{{ formatMessage(messages.notifications) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
|
||||
@click="emit('create')"
|
||||
>
|
||||
{{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
v-model="typeFilter"
|
||||
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
|
||||
@change="load(true)"
|
||||
>
|
||||
<option value="">{{ formatMessage(messages.all) }}</option>
|
||||
<option v-for="t in TYPE_OPTIONS" :key="t" :value="t">{{ typeLabel(t) }}</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
|
||||
@change="load(true)"
|
||||
>
|
||||
<option value="">{{ formatMessage(messages.all) }}</option>
|
||||
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ statusLabel(s) }}</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="priorityFilter"
|
||||
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
|
||||
@change="load(true)"
|
||||
>
|
||||
<option value="">{{ formatMessage(messages.all) }}</option>
|
||||
<option v-for="p in PRIORITY_OPTIONS" :key="p" :value="p">{{ priorityLabel(p) }}</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-1.5 text-xs text-secondary">
|
||||
<input v-model="openOnly" type="checkbox" @change="load(true)" />
|
||||
{{ formatMessage(messages.openOnly) }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
|
||||
@click="clearFilters"
|
||||
>
|
||||
{{ formatMessage(messages.all) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<p v-else-if="failed" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</p>
|
||||
<p v-else-if="!items.length" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="issue in items"
|
||||
:key="issue.id"
|
||||
class="flex cursor-pointer flex-col gap-1.5 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 hover:border-brand"
|
||||
@click="emit('open', issue.id)"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
|
||||
>
|
||||
{{ typeLabel(issue.type) }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
|
||||
#{{ issue.id }} {{ issue.title }}
|
||||
</span>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="{
|
||||
'bg-green text-contrast':
|
||||
issue.status === 'CONFIRMED' || issue.status === 'VERIFYING',
|
||||
'bg-yellow text-contrast':
|
||||
issue.status === 'NEEDS_INFO' || issue.status === 'IN_PROGRESS',
|
||||
'bg-button-bg text-secondary': issue.status === 'PENDING',
|
||||
'bg-surface-5 text-secondary': issue.status === 'CLOSED',
|
||||
}"
|
||||
>
|
||||
{{ statusLabel(issue.status) }}
|
||||
</span>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="{
|
||||
'bg-red text-contrast': issue.priority === 'CRITICAL',
|
||||
'bg-orange text-contrast': issue.priority === 'HIGH',
|
||||
'bg-button-bg text-secondary':
|
||||
issue.priority === 'NORMAL' || issue.priority === 'LOW',
|
||||
}"
|
||||
>
|
||||
{{ priorityLabel(issue.priority) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
|
||||
<span>{{ issue.authorName }}</span>
|
||||
<span>{{ formatDate(issue.createdAt) }}</span>
|
||||
<span>{{ formatMessage(messages.comments, { count: issue.commentCount }) }}</span>
|
||||
<span v-if="issue.assigneeName">→ {{ issue.assigneeName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasMore"
|
||||
type="button"
|
||||
class="w-fit self-center rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
|
||||
:disabled="loadingMore"
|
||||
@click="load(false)"
|
||||
>
|
||||
{{ formatMessage(messages.more) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type SkinSiteBugNotification,
|
||||
skinSiteBugNotifications,
|
||||
skinSiteMarkBugNotificationRead,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
const emit = defineEmits<{ (e: 'back'): void; (e: 'open', id: number): void }>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.bug-notifications.title', defaultMessage: 'Notifications' },
|
||||
back: { id: 'app.skin-site.bug-notifications.back', defaultMessage: 'Back to home' },
|
||||
subtitle: {
|
||||
id: 'app.skin-site.bug-notifications.subtitle',
|
||||
defaultMessage: 'Activity on issues you reported or follow.',
|
||||
},
|
||||
loading: { id: 'app.skin-site.bug-notifications.loading', defaultMessage: 'Loading…' },
|
||||
failed: {
|
||||
id: 'app.skin-site.bug-notifications.failed',
|
||||
defaultMessage: 'Could not load notifications.',
|
||||
},
|
||||
empty: { id: 'app.skin-site.bug-notifications.empty', defaultMessage: 'No new notifications.' },
|
||||
item: {
|
||||
id: 'app.skin-site.bug-notifications.item',
|
||||
defaultMessage: '{name} updated issue #{id}',
|
||||
},
|
||||
markAll: {
|
||||
id: 'app.skin-site.bug-notifications.mark-all',
|
||||
defaultMessage: 'Mark all as read',
|
||||
},
|
||||
markedAll: {
|
||||
id: 'app.skin-site.bug-notifications.marked-all',
|
||||
defaultMessage: 'All notifications marked as read.',
|
||||
},
|
||||
})
|
||||
|
||||
const items = ref<SkinSiteBugNotification[]>([])
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const marking = ref(false)
|
||||
|
||||
function formatDateTime(ts: number): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const result = await skinSiteBugNotifications()
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
items.value = result.payload ?? []
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
if (marking.value || !items.value.length) return
|
||||
marking.value = true
|
||||
try {
|
||||
await skinSiteMarkBugNotificationRead()
|
||||
items.value = []
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
marking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="emit('back')"
|
||||
>
|
||||
← {{ formatMessage(messages.back) }}
|
||||
</button>
|
||||
|
||||
<header class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="items.length"
|
||||
type="button"
|
||||
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="marking"
|
||||
@click="markAllRead"
|
||||
>
|
||||
{{ formatMessage(messages.markAll) }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<p v-else-if="failed" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!items.length"
|
||||
class="m-0 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="flex cursor-pointer flex-wrap items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 hover:border-brand"
|
||||
@click="emit('open', item.issueId)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-contrast">
|
||||
{{ formatMessage(messages.item, { name: item.actorName, id: item.issueId }) }}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-secondary">{{ formatDateTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { requestCaptcha, verifyCaptcha } from '@/helpers/skin-site-api'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.captcha.title', defaultMessage: 'Let us check you are human' },
|
||||
subtitle: {
|
||||
id: 'app.skin-site.captcha.subtitle',
|
||||
defaultMessage: 'Drag the slider to rotate the image upright.',
|
||||
},
|
||||
submit: { id: 'app.skin-site.captcha.submit', defaultMessage: 'Submit' },
|
||||
loading: { id: 'app.skin-site.captcha.loading', defaultMessage: 'Loading captcha…' },
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'verified', clientId: string): void; (e: 'cancel'): void }>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const image = ref('')
|
||||
const challengeClientId = ref('')
|
||||
const degree = ref(0)
|
||||
|
||||
/** The image is rotated by the server; the slider counter-rotates the preview. */
|
||||
const previewRotation = computed(() => `${degree.value}deg`)
|
||||
|
||||
function resetState() {
|
||||
degree.value = 0
|
||||
image.value = ''
|
||||
challengeClientId.value = ''
|
||||
}
|
||||
|
||||
async function loadCaptcha() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await requestCaptcha()
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
image.value = result.payload.image ?? ''
|
||||
challengeClientId.value = result.payload.clientId
|
||||
degree.value = 0
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function show() {
|
||||
resetState()
|
||||
modal.value?.show()
|
||||
await loadCaptcha()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value || !challengeClientId.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await verifyCaptcha({
|
||||
clientId: challengeClientId.value,
|
||||
degree: degree.value,
|
||||
})
|
||||
if (!result.success) {
|
||||
handleError(new Error(result.errorMessage))
|
||||
await loadCaptcha()
|
||||
return
|
||||
}
|
||||
modal.value?.hide()
|
||||
emit('verified', result.payload)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="resetState">
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.subtitle) }}</p>
|
||||
|
||||
<div
|
||||
class="relative size-64 overflow-hidden rounded-2xl border-2 border-solid border-surface-5 bg-bg"
|
||||
>
|
||||
<img
|
||||
v-if="image"
|
||||
:src="image"
|
||||
alt=""
|
||||
class="size-full object-contain transition-transform duration-75"
|
||||
:style="{ transform: `rotate(${previewRotation})` }"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="flex size-full items-center justify-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-sm">
|
||||
<input
|
||||
v-model.number="degree"
|
||||
type="range"
|
||||
min="0"
|
||||
max="360"
|
||||
step="1"
|
||||
class="w-full accent-brand"
|
||||
:disabled="loading || submitting"
|
||||
/>
|
||||
<div class="mt-1 flex items-center justify-between text-xs text-secondary">
|
||||
<span>0°</span>
|
||||
<span class="font-semibold text-contrast">{{ degree }}°</span>
|
||||
<span>360°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast"
|
||||
:disabled="loading || submitting || !image"
|
||||
@click="submit"
|
||||
>
|
||||
{{ formatMessage(loading ? messages.loading : messages.submit) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, HeartIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type SkinSitePublicDonationSummary,
|
||||
skinSitePublicDonationSummary,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
import { formatDonationAmount } from './donation-format'
|
||||
import SkinSiteDonationModal from './SkinSiteDonationModal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.home.donation.title', defaultMessage: 'Donation thanks' },
|
||||
total: { id: 'app.skin-site.home.donation.total', defaultMessage: 'Total raised' },
|
||||
donors: { id: 'app.skin-site.home.donation.donors', defaultMessage: '{count} donors' },
|
||||
unavailable: {
|
||||
id: 'app.skin-site.home.donation.unavailable',
|
||||
defaultMessage: 'Donation summary unavailable.',
|
||||
},
|
||||
loading: { id: 'app.skin-site.home.donation.loading', defaultMessage: 'Loading…' },
|
||||
})
|
||||
|
||||
const summary = ref<SkinSitePublicDonationSummary | null>(null)
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const donationModal = ref<InstanceType<typeof SkinSiteDonationModal>>()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await skinSitePublicDonationSummary()
|
||||
if (result.success) summary.value = result.payload
|
||||
else failed.value = true
|
||||
} catch {
|
||||
failed.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="donation-card" @click="donationModal?.show()">
|
||||
<span class="donation-icon"><HeartIcon class="size-6" /></span>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="text-sm font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
|
||||
<span v-if="loading" class="flex items-center gap-1 text-xs text-secondary">
|
||||
<SpinnerIcon class="size-3 animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</span>
|
||||
<span v-else-if="failed" class="text-xs text-secondary">
|
||||
{{ formatMessage(messages.unavailable) }}
|
||||
</span>
|
||||
<span v-else-if="summary" class="text-xs text-secondary">{{
|
||||
formatMessage(messages.donors, { count: summary.donorCount })
|
||||
}}</span>
|
||||
</div>
|
||||
<strong v-if="summary" class="donation-amount">{{
|
||||
formatDonationAmount(summary.totalAmount)
|
||||
}}</strong>
|
||||
<ChevronRightIcon class="size-5 shrink-0 text-secondary" />
|
||||
</button>
|
||||
<SkinSiteDonationModal ref="donationModal" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.donation-card {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-raised-bg);
|
||||
color: var(--color-contrast);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 180ms,
|
||||
background 180ms;
|
||||
}
|
||||
.donation-card:hover {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-button-bg);
|
||||
}
|
||||
.donation-card:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.donation-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-brand-highlight);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
.donation-amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.donation-card {
|
||||
gap: 0.625rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.donation-amount {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.donation-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createRenderer, nextTick } from 'vue'
|
||||
|
||||
const testGlobal = globalThis as unknown as {
|
||||
donationTestFetch: (offset: number) => Promise<unknown>
|
||||
}
|
||||
const require = createRequire(import.meta.url)
|
||||
const { parse, compileScript } = require('vue/compiler-sfc')
|
||||
const componentUrl = new URL('./SkinSiteDonationModal.vue', import.meta.url).href
|
||||
const stubs: Record<string, string> = {
|
||||
'@modrinth/assets': 'export const HeartIcon={}, SpinnerIcon={}, StarIcon={}',
|
||||
'@modrinth/ui':
|
||||
'export const Avatar={}, ButtonStyled={}, NewModal={}, commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
|
||||
'@/helpers/skin-site-api':
|
||||
'export const skinSiteDonationList=offset=>globalThis.donationTestFetch(offset)',
|
||||
}
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier in stubs)
|
||||
return {
|
||||
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
|
||||
shortCircuit: true,
|
||||
}
|
||||
if (specifier === './donation-format')
|
||||
return nextResolve(new URL('./donation-format.ts', import.meta.url).href, context)
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
load(url, context, nextLoad) {
|
||||
if (url !== componentUrl) return nextLoad(url, context)
|
||||
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
|
||||
return {
|
||||
format: 'module',
|
||||
source: stripTypeScriptTypes(compileScript(descriptor, { id: 'donation-test' }).content),
|
||||
shortCircuit: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
const { default: Donations } = await import(componentUrl)
|
||||
hooks.deregister()
|
||||
Donations.render = () => null
|
||||
type Node = Record<string, unknown>
|
||||
const renderer = createRenderer<Node, Node>({
|
||||
createElement: () => ({}),
|
||||
createText: () => ({}),
|
||||
createComment: () => ({}),
|
||||
insert() {},
|
||||
remove() {},
|
||||
setText() {},
|
||||
setElementText() {},
|
||||
patchProp() {},
|
||||
parentNode: () => null,
|
||||
nextSibling: () => null,
|
||||
})
|
||||
type State = {
|
||||
items: Array<{ id: number }>
|
||||
loading: boolean
|
||||
failed: boolean
|
||||
hasMore: boolean
|
||||
load: (reset?: boolean) => Promise<void>
|
||||
show: () => void
|
||||
}
|
||||
function mount(t: test.TestContext) {
|
||||
const app = renderer.createApp(Donations)
|
||||
const instance = app.mount({})
|
||||
t.after(() => app.unmount())
|
||||
return (instance.$ as unknown as { setupState: State }).setupState
|
||||
}
|
||||
const page = (id: number, hasMore = true) => ({
|
||||
success: true,
|
||||
payload: {
|
||||
items: [{ id, rank: id, username: 'Donor', avatar: null, amount: '12.50' }],
|
||||
summary: { totalAmount: '12.50', donorCount: 1 },
|
||||
hasMore,
|
||||
},
|
||||
})
|
||||
const flush = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
test('donations load on opening and a failed next page preserves the list and retries the same offset', async (t) => {
|
||||
const offsets: number[] = []
|
||||
let fail = false
|
||||
testGlobal.donationTestFetch = async (offset) => {
|
||||
offsets.push(offset)
|
||||
return fail ? { success: false, errorMessage: 'Offline' } : page(offset + 1, offset === 0)
|
||||
}
|
||||
const state = mount(t)
|
||||
assert.deepEqual(offsets, [])
|
||||
state.show()
|
||||
await flush()
|
||||
assert.deepEqual(
|
||||
state.items.map((item) => item.id),
|
||||
[1],
|
||||
)
|
||||
fail = true
|
||||
await state.load()
|
||||
assert.equal(state.failed, true)
|
||||
assert.deepEqual(
|
||||
state.items.map((item) => item.id),
|
||||
[1],
|
||||
)
|
||||
fail = false
|
||||
await state.load()
|
||||
assert.deepEqual(offsets, [0, 1, 1])
|
||||
assert.deepEqual(
|
||||
state.items.map((item) => item.id),
|
||||
[1, 2],
|
||||
)
|
||||
assert.equal(state.hasMore, false)
|
||||
})
|
||||
|
||||
test('reopening the donation list discards an older in-flight page', async (t) => {
|
||||
const finish: Array<(result: unknown) => void> = []
|
||||
testGlobal.donationTestFetch = () => new Promise((resolve) => finish.push(resolve))
|
||||
const state = mount(t)
|
||||
state.show()
|
||||
state.show()
|
||||
finish[1](page(2, false))
|
||||
await flush()
|
||||
finish[0](page(1))
|
||||
await flush()
|
||||
assert.deepEqual(
|
||||
state.items.map((item) => item.id),
|
||||
[2],
|
||||
)
|
||||
assert.equal(state.loading, false)
|
||||
assert.equal(state.hasMore, false)
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { HeartIcon, SpinnerIcon, StarIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
skinSiteDonationList,
|
||||
type SkinSitePublicDonationEntry,
|
||||
type SkinSitePublicDonationSummary,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
import { formatDonationAmount } from './donation-format'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.home.donation.title', defaultMessage: 'Donation thanks' },
|
||||
total: { id: 'app.skin-site.home.donation.total', defaultMessage: 'Total raised' },
|
||||
donors: { id: 'app.skin-site.home.donation.donors', defaultMessage: '{count} donors' },
|
||||
thanks: {
|
||||
id: 'app.skin-site.home.donation.thanks',
|
||||
defaultMessage: 'Thank you to everyone who helps keep StarLight running.',
|
||||
},
|
||||
empty: { id: 'app.skin-site.home.donation.empty', defaultMessage: 'No donations yet.' },
|
||||
failed: {
|
||||
id: 'app.skin-site.home.donation.list-failed',
|
||||
defaultMessage: 'Could not load donations.',
|
||||
},
|
||||
more: { id: 'app.skin-site.home.donation.more', defaultMessage: 'Load more' },
|
||||
})
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const items = ref<SkinSitePublicDonationEntry[]>([])
|
||||
const summary = ref<SkinSitePublicDonationSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const hasMore = ref(false)
|
||||
let generation = 0
|
||||
|
||||
async function load(reset = false) {
|
||||
if (loading.value && !reset) return
|
||||
const request = ++generation
|
||||
if (reset) {
|
||||
items.value = []
|
||||
summary.value = null
|
||||
hasMore.value = false
|
||||
}
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const result = await skinSiteDonationList(items.value.length)
|
||||
if (request !== generation) return
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
items.value = [...items.value, ...result.payload.items]
|
||||
summary.value = result.payload.summary
|
||||
hasMore.value = result.payload.hasMore
|
||||
} catch {
|
||||
if (request === generation) failed.value = true
|
||||
} finally {
|
||||
if (request === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
void load(true)
|
||||
}
|
||||
onUnmounted(() => {
|
||||
generation++
|
||||
})
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
max-width="42rem"
|
||||
width="42rem"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="donation-summary">
|
||||
<HeartIcon class="size-7 text-brand" />
|
||||
<span class="text-sm text-secondary">{{ formatMessage(messages.total) }}</span>
|
||||
<strong v-if="summary" class="text-3xl text-contrast tabular-nums">{{
|
||||
formatDonationAmount(summary.totalAmount)
|
||||
}}</strong>
|
||||
<span v-if="summary" class="text-sm text-secondary">{{
|
||||
formatMessage(messages.donors, { count: summary.donorCount })
|
||||
}}</span>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.thanks) }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="loading && !items.length"
|
||||
class="flex justify-center p-6 text-secondary"
|
||||
role="status"
|
||||
>
|
||||
<SpinnerIcon class="size-6 animate-spin" />
|
||||
</div>
|
||||
<ol v-if="items.length" class="donation-list">
|
||||
<li v-for="donor in items" :key="donor.id" class="donor-row">
|
||||
<span class="donor-rank" :class="{ podium: donor.rank <= 3 }"
|
||||
><StarIcon v-if="donor.rank <= 3" class="size-4" /><span v-else>#</span
|
||||
>{{ donor.rank }}</span
|
||||
>
|
||||
<Avatar :src="donor.avatar ?? undefined" :alt="donor.username" size="2.25rem" />
|
||||
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
|
||||
donor.username
|
||||
}}</span>
|
||||
<strong class="shrink-0 tabular-nums text-contrast">{{
|
||||
formatDonationAmount(donor.amount)
|
||||
}}</strong>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else-if="!loading && !failed" class="m-0 py-4 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="failed"
|
||||
class="flex items-center justify-center gap-3 text-sm text-secondary"
|
||||
role="alert"
|
||||
>
|
||||
<span>{{ formatMessage(messages.failed) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="border-0 bg-transparent text-brand cursor-pointer"
|
||||
@click="load()"
|
||||
>
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="hasMore && !failed" #actions>
|
||||
<ButtonStyled
|
||||
><button type="button" :disabled="loading" @click="load()">
|
||||
<SpinnerIcon v-if="loading" class="size-4 animate-spin" />{{
|
||||
formatMessage(messages.more)
|
||||
}}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.donation-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-bg);
|
||||
text-align: center;
|
||||
}
|
||||
.donation-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.donor-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.donor-row:last-child {
|
||||
border: 0;
|
||||
}
|
||||
.donor-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
width: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.donor-rank.podium {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
|
||||
import {
|
||||
skinSitePublicDownloadCategories,
|
||||
type SkinSitePublicDownloadCategory,
|
||||
type SkinSitePublicDownloadItem,
|
||||
skinSitePublicDownloadList,
|
||||
type SkinSitePublicDownloadStatus,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.downloads.title', defaultMessage: 'Downloads' },
|
||||
subtitle: {
|
||||
id: 'app.skin-site.downloads.subtitle',
|
||||
defaultMessage: 'Files shared by the server. Pick a category to narrow the list.',
|
||||
},
|
||||
back: { id: 'app.skin-site.downloads.back', defaultMessage: 'Back to home' },
|
||||
all: { id: 'app.skin-site.downloads.all', defaultMessage: 'All' },
|
||||
empty: { id: 'app.skin-site.downloads.empty', defaultMessage: 'Nothing to download yet.' },
|
||||
loading: { id: 'app.skin-site.downloads.loading', defaultMessage: 'Loading…' },
|
||||
failed: { id: 'app.skin-site.downloads.failed', defaultMessage: 'Could not load downloads.' },
|
||||
goDownload: { id: 'app.skin-site.downloads.go', defaultMessage: 'Download' },
|
||||
uploader: { id: 'app.skin-site.downloads.uploader', defaultMessage: 'by {name}' },
|
||||
statusOk: { id: 'app.skin-site.downloads.status.ok', defaultMessage: 'OK' },
|
||||
statusNew: { id: 'app.skin-site.downloads.status.new', defaultMessage: 'New' },
|
||||
statusLegacy: { id: 'app.skin-site.downloads.status.legacy', defaultMessage: 'Legacy' },
|
||||
statusOutdated: { id: 'app.skin-site.downloads.status.outdated', defaultMessage: 'Outdated' },
|
||||
statusRemoval: { id: 'app.skin-site.downloads.status.removal', defaultMessage: 'Removal' },
|
||||
signedOut: {
|
||||
id: 'app.skin-site.downloads.signed-out',
|
||||
defaultMessage: 'Sign in to browse downloads.',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'back'): void }>()
|
||||
|
||||
const items = ref<SkinSitePublicDownloadItem[]>([])
|
||||
const categories = ref<SkinSitePublicDownloadCategory[]>([])
|
||||
const selectedCategory = ref<string | null>(null)
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
|
||||
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
|
||||
|
||||
const filtered = computed(() =>
|
||||
selectedCategory.value
|
||||
? items.value.filter((item) => item.category.includes(selectedCategory.value!))
|
||||
: items.value,
|
||||
)
|
||||
|
||||
function statusLabel(status: SkinSitePublicDownloadStatus): string {
|
||||
switch (status) {
|
||||
case 'NEW':
|
||||
return formatMessage(messages.statusNew)
|
||||
case 'LEGACY':
|
||||
return formatMessage(messages.statusLegacy)
|
||||
case 'OUTDATED':
|
||||
return formatMessage(messages.statusOutdated)
|
||||
case 'REMOVAL':
|
||||
return formatMessage(messages.statusRemoval)
|
||||
default:
|
||||
return formatMessage(messages.statusOk)
|
||||
}
|
||||
}
|
||||
|
||||
async function openUrl(url: string) {
|
||||
const { openUrl: open } = await import('@tauri-apps/plugin-opener')
|
||||
await open(url)
|
||||
}
|
||||
|
||||
function download(item: SkinSitePublicDownloadItem) {
|
||||
const route = item.routes?.[0]
|
||||
void openUrl(route?.url ?? item.url)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
if (!signedIn.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const [listResult, categoryResult] = await Promise.all([
|
||||
skinSitePublicDownloadList(),
|
||||
skinSitePublicDownloadCategories(),
|
||||
])
|
||||
if (!listResult.success) throw new Error(listResult.errorMessage)
|
||||
items.value = listResult.payload ?? []
|
||||
if (categoryResult.success) categories.value = categoryResult.payload ?? []
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="emit('back')"
|
||||
>
|
||||
← {{ formatMessage(messages.back) }}
|
||||
</button>
|
||||
|
||||
<header class="flex flex-col gap-1">
|
||||
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
|
||||
</header>
|
||||
|
||||
<div v-if="categories.length" class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
|
||||
:class="
|
||||
selectedCategory === null
|
||||
? 'bg-brand text-contrast'
|
||||
: 'bg-button-bg text-secondary hover:brightness-110'
|
||||
"
|
||||
@click="selectedCategory = null"
|
||||
>
|
||||
{{ formatMessage(messages.all) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
|
||||
:class="
|
||||
selectedCategory === category.name
|
||||
? 'bg-brand text-contrast'
|
||||
: 'bg-button-bg text-secondary hover:brightness-110'
|
||||
"
|
||||
@click="selectedCategory = category.name"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!signedIn" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.signedOut) }}
|
||||
</p>
|
||||
<div v-else-if="loading" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<p v-else-if="failed" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</p>
|
||||
<p v-else-if="!filtered.length" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="item in filtered"
|
||||
:key="item.id"
|
||||
class="flex flex-wrap items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="min-w-0 truncate text-sm font-semibold text-contrast">
|
||||
{{ item.filename }}
|
||||
</span>
|
||||
<span
|
||||
v-for="status in item.status"
|
||||
:key="status"
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="{
|
||||
'bg-green text-contrast': status === 'NEW',
|
||||
'bg-yellow text-contrast': status === 'LEGACY',
|
||||
'bg-red text-contrast': status === 'OUTDATED' || status === 'REMOVAL',
|
||||
'bg-button-bg text-secondary':
|
||||
status !== 'NEW' &&
|
||||
status !== 'LEGACY' &&
|
||||
status !== 'OUTDATED' &&
|
||||
status !== 'REMOVAL',
|
||||
}"
|
||||
>
|
||||
{{ statusLabel(status) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="item.description" class="truncate text-xs text-secondary">
|
||||
{{ item.description }}
|
||||
</span>
|
||||
<span v-if="item.uploader" class="text-xs text-secondary">
|
||||
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1 rounded-full bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="download(item)"
|
||||
>
|
||||
<DownloadIcon class="size-3.5" />
|
||||
{{ formatMessage(messages.goDownload) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { skinSiteUser } from '@/composables/skin-site-session'
|
||||
|
||||
import SkinSiteAdminView from './SkinSiteAdminView.vue'
|
||||
import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
|
||||
import SkinSiteBugCreateModal from './SkinSiteBugCreateModal.vue'
|
||||
import SkinSiteBugDetailView from './SkinSiteBugDetailView.vue'
|
||||
import SkinSiteBugListView from './SkinSiteBugListView.vue'
|
||||
import SkinSiteBugNotificationsView from './SkinSiteBugNotificationsView.vue'
|
||||
import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
|
||||
import SkinSiteDownloadsView from './SkinSiteDownloadsView.vue'
|
||||
import SkinSiteMapView from './SkinSiteMapView.vue'
|
||||
import SkinSiteNavCards from './SkinSiteNavCards.vue'
|
||||
import SkinSitePlayerPreview from './SkinSitePlayerPreview.vue'
|
||||
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
|
||||
import SkinSiteProfileView from './SkinSiteProfileView.vue'
|
||||
import SkinSiteReviewView from './SkinSiteReviewView.vue'
|
||||
import WikiView from './WikiView.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
account: { id: 'app.skin-site.home.settings', defaultMessage: 'Account' },
|
||||
admin: { id: 'app.skin-site.home.admin', defaultMessage: 'Admin' },
|
||||
})
|
||||
const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>()
|
||||
const isAdmin = computed(() =>
|
||||
skinSiteUser.value?.permissions.some(
|
||||
(permission) => permission === 'WEB_ADMIN' || permission === 'SERVER_ADMIN',
|
||||
),
|
||||
)
|
||||
|
||||
const view = ref<
|
||||
| 'home'
|
||||
| 'players'
|
||||
| 'downloads'
|
||||
| 'map'
|
||||
| 'review'
|
||||
| 'bugs'
|
||||
| 'bug-detail'
|
||||
| 'bug-notifications'
|
||||
| 'admin'
|
||||
| 'wiki'
|
||||
>('home')
|
||||
const selectedBugId = ref<number | null>(null)
|
||||
|
||||
function openBug(id: number) {
|
||||
selectedBugId.value = id
|
||||
view.value = 'bug-detail'
|
||||
}
|
||||
|
||||
function backToBugList() {
|
||||
selectedBugId.value = null
|
||||
view.value = 'bugs'
|
||||
}
|
||||
|
||||
const bugCreateModal = ref<InstanceType<typeof SkinSiteBugCreateModal>>()
|
||||
|
||||
function openBugNotifications() {
|
||||
view.value = 'bug-notifications'
|
||||
}
|
||||
|
||||
function onBugCreated(id: number) {
|
||||
openBug(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 w-full flex-col overflow-y-auto">
|
||||
<div class="skin-site-home mx-auto flex w-full max-w-6xl flex-col gap-6 p-6">
|
||||
<template v-if="view === 'players'">
|
||||
<div v-if="skinSiteUser" class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled
|
||||
><button type="button" @click="profileModal?.show()">
|
||||
{{ formatMessage(messages.account) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled v-if="isAdmin"
|
||||
><button type="button" @click="view = 'admin'">
|
||||
{{ formatMessage(messages.admin) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
<SkinSiteProfileView @back="view = 'home'" />
|
||||
</template>
|
||||
<SkinSiteDownloadsView v-else-if="view === 'downloads'" @back="view = 'home'" />
|
||||
<SkinSiteMapView v-else-if="view === 'map'" @back="view = 'home'" />
|
||||
<SkinSiteReviewView v-else-if="view === 'review'" @back="view = 'home'" />
|
||||
<SkinSiteBugListView
|
||||
v-else-if="view === 'bugs'"
|
||||
@back="view = 'home'"
|
||||
@open="openBug"
|
||||
@create="bugCreateModal?.show()"
|
||||
@notifications="openBugNotifications"
|
||||
/>
|
||||
<SkinSiteBugNotificationsView
|
||||
v-else-if="view === 'bug-notifications'"
|
||||
@back="view = 'bugs'"
|
||||
@open="openBug"
|
||||
/>
|
||||
<SkinSiteAdminView v-else-if="view === 'admin'" @back="view = 'home'" />
|
||||
<WikiView v-else-if="view === 'wiki'" initial-title="The Land Of StarLight" />
|
||||
|
||||
<SkinSiteBugDetailView
|
||||
v-else-if="view === 'bug-detail' && selectedBugId !== null"
|
||||
:issue-id="selectedBugId"
|
||||
@back="backToBugList"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div class="home-overview">
|
||||
<SkinSitePlayerPreview />
|
||||
<SkinSiteAnnouncements />
|
||||
</div>
|
||||
|
||||
<SkinSiteDonationCard />
|
||||
</template>
|
||||
<div class="home-toolbar">
|
||||
<SkinSiteNavCards
|
||||
:active="view === 'bug-detail' || view === 'bug-notifications' ? 'bugs' : view"
|
||||
@navigate="(target) => (view = target === view ? 'home' : target)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SkinSiteBugCreateModal ref="bugCreateModal" @created="onBugCreated" />
|
||||
<SkinSiteProfileModal ref="profileModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skin-site-home {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.home-overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
.home-toolbar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
margin-top: auto;
|
||||
padding-top: 0.75rem;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
@container (max-width: 48rem) {
|
||||
.home-overview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { reloadSkinSiteSession, signInSkinSite } from '@/composables/skin-site-session'
|
||||
|
||||
import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue'
|
||||
import SkinSitePasswordResetModal from './SkinSitePasswordResetModal.vue'
|
||||
import SkinSiteRegisterModal from './SkinSiteRegisterModal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError, addNotification } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.login.title', defaultMessage: 'Sign in to StarLight' },
|
||||
username: { id: 'app.skin-site.login.username', defaultMessage: 'Username or email' },
|
||||
password: { id: 'app.skin-site.login.password', defaultMessage: 'Password' },
|
||||
signIn: { id: 'app.skin-site.login.sign-in', defaultMessage: 'Sign in' },
|
||||
signingIn: { id: 'app.skin-site.login.signing-in', defaultMessage: 'Signing in…' },
|
||||
register: { id: 'app.skin-site.login.register', defaultMessage: 'Create account' },
|
||||
forgotPassword: { id: 'app.skin-site.login.forgot', defaultMessage: 'Forgot password?' },
|
||||
usernameHint: {
|
||||
id: 'app.skin-site.login.username-hint',
|
||||
defaultMessage: '3–16 characters, or a registered email address.',
|
||||
},
|
||||
passwordHint: {
|
||||
id: 'app.skin-site.login.password-hint',
|
||||
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
|
||||
},
|
||||
success: { id: 'app.skin-site.login.success', defaultMessage: 'Signed in to StarLight.' },
|
||||
})
|
||||
|
||||
const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const captchaModal = ref<InstanceType<typeof SkinSiteCaptchaModal>>()
|
||||
const registerModal = ref<InstanceType<typeof SkinSiteRegisterModal>>()
|
||||
const passwordResetModal = ref<InstanceType<typeof SkinSitePasswordResetModal>>()
|
||||
|
||||
function openRegister() {
|
||||
modal.value?.hide()
|
||||
registerModal.value?.show()
|
||||
}
|
||||
|
||||
function openPasswordReset() {
|
||||
modal.value?.hide()
|
||||
passwordResetModal.value?.show()
|
||||
}
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const working = ref(false)
|
||||
|
||||
const emit = defineEmits<{ (e: 'signed-in' | 'cancel'): void }>()
|
||||
|
||||
const usernameValid = computed(() => {
|
||||
const value = username.value.trim()
|
||||
if (!value) return false
|
||||
if (value.includes('@')) return value.length < 64
|
||||
return value.length >= 3 && value.length <= 16
|
||||
})
|
||||
|
||||
const passwordValid = computed(() => PASSWORD_RE.test(password.value))
|
||||
const canSubmit = computed(() => usernameValid.value && passwordValid.value && !working.value)
|
||||
|
||||
function reset() {
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
working.value = false
|
||||
}
|
||||
|
||||
function show() {
|
||||
reset()
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function beginSignIn() {
|
||||
if (!canSubmit.value) return
|
||||
captchaModal.value?.show()
|
||||
}
|
||||
|
||||
async function onCaptchaVerified(clientId: string) {
|
||||
working.value = true
|
||||
try {
|
||||
const result = await signInSkinSite({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
clientId,
|
||||
})
|
||||
if (!result.success) {
|
||||
handleError(new Error(result.errorMessage))
|
||||
return
|
||||
}
|
||||
await reloadSkinSiteSession()
|
||||
addNotification({ type: 'success', title: formatMessage(messages.success) })
|
||||
modal.value?.hide()
|
||||
reset()
|
||||
emit('signed-in')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function onRegisterSignedIn() {
|
||||
emit('signed-in')
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.username) }}</span>
|
||||
<StyledInput v-model="username" type="text" autocomplete="username" />
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.usernameHint) }}</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.password) }}</span>
|
||||
<StyledInput v-model="password" type="password" autocomplete="current-password" />
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
|
||||
</label>
|
||||
<div class="flex justify-between text-sm">
|
||||
<button type="button" class="text-brand hover:underline" @click="openRegister">
|
||||
{{ formatMessage(messages.register) }}
|
||||
</button>
|
||||
<button type="button" class="text-brand hover:underline" @click="openPasswordReset">
|
||||
{{ formatMessage(messages.forgotPassword) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast disabled:opacity-50"
|
||||
:disabled="!canSubmit"
|
||||
@click="beginSignIn"
|
||||
>
|
||||
{{ formatMessage(working ? messages.signingIn : messages.signIn) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<SkinSiteRegisterModal
|
||||
ref="registerModal"
|
||||
@signed-in="onRegisterSignedIn"
|
||||
@cancel="modal?.show()"
|
||||
/>
|
||||
<SkinSitePasswordResetModal
|
||||
ref="passwordResetModal"
|
||||
@done="modal?.show()"
|
||||
@cancel="modal?.show()"
|
||||
/>
|
||||
|
||||
<SkinSiteCaptchaModal
|
||||
ref="captchaModal"
|
||||
@verified="onCaptchaVerified"
|
||||
@cancel="working = false"
|
||||
/>
|
||||
</template>
|
||||
Loaded 100 of 589 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user