Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
15c375bca5 | ||
|
|
d2a319a4d5 | ||
|
|
2359b80250 | ||
|
|
c273734aec | ||
|
|
f89cbff37e | ||
|
|
f92aee9b4c | ||
|
|
2192a19d50 | ||
|
|
9411d0945a | ||
|
|
e1cca236ee | ||
|
|
2edf079724 | ||
|
|
6ce39f3a42 | ||
|
|
6e7f982ee6 | ||
|
|
a8c630cbc8 | ||
|
|
bb55230c3a | ||
|
|
1019dcf478 | ||
|
|
b14c50c2f1 | ||
|
|
1cf67602d8 | ||
|
|
91f483b960 | ||
|
|
19098f7159 | ||
|
|
c2bc7c2441 | ||
|
|
e4fa234df1 | ||
|
|
42b0c7e7af | ||
|
|
405d980311 | ||
|
|
905e905eca | ||
|
|
cda7bb284a | ||
|
|
ffe529df57 | ||
|
|
984f3ddbd6 | ||
|
|
a477b1fb9a | ||
|
|
a47d309103 | ||
|
|
392f01ea4b | ||
|
|
5006f432dc | ||
|
|
51fb0c30f7 | ||
|
|
bd09fed0ed | ||
|
|
bc904065c3 | ||
|
|
5d473ebfbc | ||
|
|
0b30fc96a0 | ||
|
|
2a1dfa3c42 | ||
|
|
fbcaed528b | ||
|
|
9dd2b013af | ||
|
|
c50595b252 | ||
|
|
3471cd3da2 | ||
|
|
1593ac7a7c | ||
|
|
1fa56add19 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Axolotl release
|
||||
name: Starlight release
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -51,8 +51,18 @@ 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
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]; then
|
||||
echo '::error::Configure TAURI_SIGNING_PRIVATE_KEY with the private key matching apps/app/tauri-release.conf.json before publishing updates.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Prepare draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -72,7 +82,7 @@ jobs:
|
||||
release_args=(
|
||||
--draft
|
||||
--notes-file "$release_notes"
|
||||
--title "Axolotl Launcher ${GITHUB_REF_NAME}"
|
||||
--title "Starlight Launcher ${GITHUB_REF_NAME}"
|
||||
)
|
||||
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
|
||||
release_args+=(--prerelease)
|
||||
@@ -121,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'
|
||||
@@ -175,7 +197,7 @@ jobs:
|
||||
tauriScript: pnpm tauri
|
||||
args: ${{ matrix.args }}
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: Axolotl Launcher ${{ github.ref_name }}
|
||||
releaseName: Starlight Launcher ${{ github.ref_name }}
|
||||
releaseDraft: true
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
generateReleaseNotes: false
|
||||
@@ -183,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:
|
||||
@@ -198,7 +215,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ github.ref_name }}" -replace '^v', ''
|
||||
$appName = "Axolotl Launcher"
|
||||
$appName = (Get-Content apps/app/tauri.conf.json -Raw | ConvertFrom-Json).mainBinaryName
|
||||
$outDir = "target/release/bundle/nsis"
|
||||
$buildDir = "target/portable-build"
|
||||
$publishDir = Join-Path $env:RUNNER_TEMP "windows-assets"
|
||||
@@ -249,6 +266,15 @@ jobs:
|
||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null
|
||||
Copy-Item -Force $appExe "$buildDir\Axolotl\"
|
||||
|
||||
$editorResources = "target/release/resources/blockbench-skin"
|
||||
foreach ($requiredFile in @('index.html', 'css/setup.css', 'dist/skin.bundle.js.gz')) {
|
||||
if (!(Test-Path -LiteralPath (Join-Path $editorResources $requiredFile) -PathType Leaf)) {
|
||||
throw "Missing skin editor resource: $requiredFile"
|
||||
}
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\resources" | Out-Null
|
||||
Copy-Item -LiteralPath $editorResources -Destination "$buildDir\Axolotl\resources" -Recurse -Force
|
||||
|
||||
# Create empty .Axolotl folder to trigger portable mode
|
||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null
|
||||
|
||||
@@ -264,22 +290,55 @@ 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 }}
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
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 }}
|
||||
run: gh release download "${GITHUB_REF_NAME}" --pattern '*.sig' --dir release-check/signatures
|
||||
|
||||
# Publish the draft release before generating the updater manifest. While
|
||||
# the release is a draft GitHub reports asset URLs as
|
||||
# `.../releases/download/untagged-<hash>/...`; only after publishing does
|
||||
# the manifest point at the stable `.../releases/download/<tag>/...` path.
|
||||
- name: Publish release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
|
||||
gh release edit "${GITHUB_REF_NAME}" --draft=false --prerelease
|
||||
else
|
||||
gh release edit "${GITHUB_REF_NAME}" --draft=false --latest
|
||||
fi
|
||||
|
||||
- name: Download release metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -306,31 +365,6 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${GITHUB_REF_NAME}" release-check/latest.json --clobber
|
||||
|
||||
- name: Publish release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
|
||||
gh release edit "${GITHUB_REF_NAME}" --draft=false --prerelease
|
||||
else
|
||||
gh release edit "${GITHUB_REF_NAME}" --draft=false --latest
|
||||
fi
|
||||
|
||||
- name: Refresh published GitHub release metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
release_id="$(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases" \
|
||||
--paginate \
|
||||
--jq ".[] | select(.tag_name == \"${GITHUB_REF_NAME}\") | .id"
|
||||
)"
|
||||
if [[ -z "$release_id" ]]; then
|
||||
echo "Published release ${GITHUB_REF_NAME} was not found"
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > release-check/release.json
|
||||
|
||||
- name: Build Update Server artifact catalog
|
||||
run: |
|
||||
node scripts/axolotl/create-update-server-catalog.mjs \
|
||||
@@ -339,7 +373,41 @@ 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:
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
|
||||
@@ -350,6 +418,13 @@ jobs:
|
||||
release-check/release.json \
|
||||
"${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Check out website branch
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
with:
|
||||
ref: main
|
||||
clean: false
|
||||
|
||||
- name: Publish website download metadata
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
env:
|
||||
@@ -372,11 +447,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Mirror published release to CNB
|
||||
if: ${{ env.CNB_TOKEN != '' && env.CNB_REPO_URL_HTTPS != '' }}
|
||||
env:
|
||||
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
CNB_TOKEN_USER_NAME: ${{ secrets.CNB_TOKEN_USER_NAME }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_API_BASE_URL: https://api.github.com/repos/${{ github.repository }}
|
||||
GITHUB_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CNB_TOKEN:-}" ]]; then
|
||||
@@ -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}"
|
||||
@@ -19,6 +19,7 @@ concurrency:
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync Git ref
|
||||
if: ${{ vars.ENABLE_CNB_SYNC == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -38,8 +39,8 @@ jobs:
|
||||
DELETED_REF_TYPE: ${{ github.event.ref_type }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CNB_TOKEN:-}" ]]; then
|
||||
echo "CNB_TOKEN is not configured"
|
||||
if [[ -z "${CNB_TOKEN:-}" || -z "${CNB_REPO_URL_HTTPS:-}" ]]; then
|
||||
echo "CNB_TOKEN and CNB_REPO_URL_HTTPS must be configured"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
replay_pid*.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
@@ -79,6 +80,7 @@ access-jwt*.txt
|
||||
|
||||
# Codex local verification artifacts
|
||||
/.codex-*
|
||||
/.tmp/
|
||||
|
||||
# Oh My Code
|
||||
.omc/
|
||||
|
||||
@@ -106,7 +106,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
"utoipa",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -707,7 +707,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1431,21 +1431,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-rich-presence"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"thiserror 2.0.19",
|
||||
"uuid 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
@@ -1904,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"
|
||||
@@ -3492,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"
|
||||
@@ -3570,7 +3586,7 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"tagptr",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5117,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"
|
||||
@@ -5300,7 +5322,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5773,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"
|
||||
@@ -5904,7 +5935,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5984,7 +6015,7 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
@@ -6022,7 +6053,7 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
@@ -6048,7 +6079,7 @@ dependencies = [
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6454,7 +6485,7 @@ dependencies = [
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
"url",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -6762,7 +6793,7 @@ dependencies = [
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -6810,7 +6841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus"
|
||||
version = "1.9.6-beta.3"
|
||||
version = "1.0.2-beta13"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"async-minecraft-ping",
|
||||
@@ -6833,7 +6864,6 @@ dependencies = [
|
||||
"data-url",
|
||||
"derive_more",
|
||||
"dirs",
|
||||
"discord-rich-presence",
|
||||
"dotenvy",
|
||||
"dunce",
|
||||
"either",
|
||||
@@ -6901,7 +6931,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
"whoami",
|
||||
"windows 0.61.3",
|
||||
@@ -6915,7 +6945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_gui"
|
||||
version = "1.9.6-beta.3"
|
||||
version = "1.0.2-beta13"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"async_zip",
|
||||
@@ -6928,6 +6958,7 @@ dependencies = [
|
||||
"either",
|
||||
"enumset",
|
||||
"flate2",
|
||||
"fontdb",
|
||||
"futures",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
@@ -6942,6 +6973,7 @@ dependencies = [
|
||||
"path-util",
|
||||
"quartz_nbt",
|
||||
"regex",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
@@ -6967,7 +6999,7 @@ dependencies = [
|
||||
"trash",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"windows 0.61.3",
|
||||
"zip 6.0.0",
|
||||
]
|
||||
@@ -7691,15 +7723,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.0"
|
||||
@@ -8868,7 +8891,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid 1.24.0",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.4",
|
||||
"zbus_macros",
|
||||
|
||||
@@ -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,16 +30,13 @@ 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"
|
||||
data-url = "0.3.2"
|
||||
derive_more = "2.1.1"
|
||||
dirs = "6.0.0"
|
||||
discord-rich-presence = "1.0.0"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.5"
|
||||
either = "1.15.0"
|
||||
@@ -49,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"
|
||||
@@ -59,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"
|
||||
@@ -89,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"
|
||||
@@ -128,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"
|
||||
@@ -150,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://git.starlight.cool/AxTps/Starlight_Lancher/actions">
|
||||
<img src="https://img.shields.io/badge/Repo-git.starlight.cool-blue?style=for-the-badge&logo=git" alt="Repository" />
|
||||
<a href="https://github.com/ApTx2012/Starlight_Lancher/actions">
|
||||
<img src="https://img.shields.io/badge/Repo-GitHub-black?style=for-the-badge&logo=github" alt="Repository" />
|
||||
</a>
|
||||
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/releases">
|
||||
<a href="https://github.com/ApTx2012/Starlight_Lancher/releases">
|
||||
<img src="https://img.shields.io/badge/Releases-Download-green?style=for-the-badge&logo=github" alt="Releases" />
|
||||
</a>
|
||||
<a href="https://git.starlight.cool/AxTps/Starlight_Lancher/stargazers">
|
||||
<a href="https://github.com/ApTx2012/Starlight_Lancher/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://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest">下载最新版</a> |
|
||||
<a href="https://github.com/ApTx2012/Starlight_Lancher/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://git.starlight.cool/AxTps/Starlight_Lancher/releases/latest) 下载适合你操作系统的最新安装包。
|
||||
请前往 [Releases](https://github.com/ApTx2012/Starlight_Lancher/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]
|
||||
|
||||
@@ -22,6 +22,10 @@ Item identifiers, readable names, and textures are sourced from the `minecraft-t
|
||||
|
||||
The recipe data and item artwork ultimately derive from Minecraft data generator output and Minecraft game resources. Minecraft and its original resources are Copyright Mojang Studios / Microsoft and are used only to identify compatible game content. Axolotl Launcher is not affiliated with or endorsed by Mojang Studios or Microsoft.
|
||||
|
||||
## About-page scene attribution
|
||||
|
||||
The about-page victory illustration is the user-approved generated artwork. Its bundled scene layers preserve the approved preview without regenerating the illustration at runtime. The supplied netherite sword artwork and the unmodified `clouds.png` and `moon_phases.png` textures from Minecraft: Java Edition 1.20.6 are Minecraft resources, Copyright Mojang Studios / Microsoft. These assets are stored in `src/assets/about-scene` and do not require a network connection.
|
||||
|
||||
## AI integration attribution
|
||||
|
||||
The AI provider settings information architecture, provider catalog, and provider descriptions are adapted from [LobeChat](https://github.com/lobehub/lobe-chat) at commit `a27dfaeda1ab499ac024a6eb0448917b216ba8a1`. The localized provider descriptions under `src/data/lobehub-provider-descriptions` are reproduced from that release. Bundled text-model metadata is synchronized separately from [LobeHub's model bank](https://github.com/lobehub/lobehub/tree/main/packages/model-bank/src/aiModels), with its exact source revision recorded in the backend catalog. LobeChat is distributed under the LobeHub Community License; a verbatim copy of that license is provided in [third-party/licenses/LobeHub-Community-License.txt](../../third-party/licenses/LobeHub-Community-License.txt).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta1",
|
||||
"version": "1.0.2-beta13",
|
||||
"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",
|
||||
|
||||
@@ -3,12 +3,12 @@ import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrin
|
||||
import {
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
GlobeIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
FlaskConicalIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
ImagesIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SettingsIcon,
|
||||
SpinnerIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
WorldIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -57,18 +58,21 @@ import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMe
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||
import { hideAllPoppers } from 'floating-vue'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
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, 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'
|
||||
@@ -85,20 +89,22 @@ import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerMod
|
||||
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
|
||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||
import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue'
|
||||
import PrivacyConsentModal from '@/components/ui/modal/PrivacyConsentModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
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 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'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable } from '@/helpers/auth.js'
|
||||
import { get_user, get_version } from '@/helpers/cache.js'
|
||||
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
|
||||
@@ -113,22 +119,22 @@ import {
|
||||
} from '@/helpers/events.js'
|
||||
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
|
||||
import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance'
|
||||
import { PlayerSelectionNavigatedAwayError } from '@/helpers/instance-player'
|
||||
import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
||||
import {
|
||||
get as getSettings,
|
||||
getLastBrowseContentProjectType,
|
||||
getPrivacySettings,
|
||||
getUpdateChannel,
|
||||
getUpdatePreferences,
|
||||
isBrowseContentProjectType,
|
||||
type PrivacySettings,
|
||||
savePrivacySettings,
|
||||
set as setSettings,
|
||||
} from '@/helpers/settings.ts'
|
||||
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
|
||||
import { get_opening_command, initialize_state, set_discord_activity } from '@/helpers/state'
|
||||
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,
|
||||
@@ -140,7 +146,6 @@ import {
|
||||
isDev,
|
||||
isElevated,
|
||||
isNetworkMetered,
|
||||
restartApp,
|
||||
setRestartAfterPendingUpdate,
|
||||
} from '@/helpers/utils.js'
|
||||
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
|
||||
@@ -181,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 (
|
||||
@@ -314,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('')
|
||||
@@ -369,8 +392,6 @@ watch(
|
||||
)
|
||||
|
||||
const stateInitialized = ref(false)
|
||||
const privacyConsentModal = ref<InstanceType<typeof PrivacyConsentModal>>()
|
||||
const privacyConsentPending = ref(false)
|
||||
const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
|
||||
const closeChoiceOpen = ref(false)
|
||||
const closeChoiceRemember = ref(false)
|
||||
@@ -494,9 +515,16 @@ onMounted(async () => {
|
||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, handleDirectLinkSyncReport)
|
||||
|
||||
try {
|
||||
await stateInitialization
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
checkUpdates()
|
||||
void warnIfRunningElevated()
|
||||
startDirectLinkSync()
|
||||
maybeShowStartupAnnouncement()
|
||||
})
|
||||
|
||||
let directLinkSync: (() => Promise<void>) | undefined
|
||||
@@ -737,10 +765,6 @@ const messages = defineMessages({
|
||||
id: 'app.navigation.skin-selector',
|
||||
defaultMessage: 'Skin selector',
|
||||
},
|
||||
starlightSkin: {
|
||||
id: 'app.navigation.starlight-skin',
|
||||
defaultMessage: '斯达莱特',
|
||||
},
|
||||
library: {
|
||||
id: 'app.navigation.library',
|
||||
defaultMessage: 'Library',
|
||||
@@ -749,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',
|
||||
@@ -765,9 +793,9 @@ const messages = defineMessages({
|
||||
id: 'app.account.signed-in-as',
|
||||
defaultMessage: 'Signed in as',
|
||||
},
|
||||
playingAs: {
|
||||
id: 'app.minecraft.playing-as',
|
||||
defaultMessage: 'Playing as',
|
||||
userInformation: {
|
||||
id: 'app.minecraft.user-information',
|
||||
defaultMessage: 'User information',
|
||||
},
|
||||
collapseSidebar: {
|
||||
id: 'app.sidebar.collapse',
|
||||
@@ -1055,9 +1083,6 @@ async function setupApp() {
|
||||
theme,
|
||||
accent_color,
|
||||
locale,
|
||||
telemetry,
|
||||
telemetry_consent_version,
|
||||
discord_rpc,
|
||||
collapsed_navigation,
|
||||
hide_nametag_skins_page,
|
||||
advanced_rendering,
|
||||
@@ -1077,6 +1102,7 @@ async function setupApp() {
|
||||
close_behavior,
|
||||
developer_mode,
|
||||
feature_flags,
|
||||
ui_scale,
|
||||
} = initialSettings
|
||||
|
||||
// Initialize locale from saved settings
|
||||
@@ -1101,13 +1127,13 @@ async function setupApp() {
|
||||
const dev = await isDev()
|
||||
isDevEnvironment.value = dev
|
||||
if (!onboarded && route.path !== '/') await router.replace('/')
|
||||
privacyConsentPending.value = telemetry_consent_version < 1
|
||||
showOnboarding.value = false
|
||||
onboardingSettings.value = initialSettings
|
||||
|
||||
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)
|
||||
@@ -1132,17 +1158,9 @@ async function setupApp() {
|
||||
themeStore.devMode = developer_mode
|
||||
themeStore.featureFlags = feature_flags
|
||||
stateInitialized.value = true
|
||||
if (privacyConsentPending.value) {
|
||||
await nextTick()
|
||||
privacyConsentModal.value?.show({
|
||||
telemetry,
|
||||
discord_rpc,
|
||||
consent_version: telemetry_consent_version,
|
||||
})
|
||||
} else {
|
||||
showOnboarding.value = !onboarded
|
||||
}
|
||||
showOnboarding.value = !onboarded
|
||||
void reconcileMojangAuthSourceAtStartup().catch(handleError)
|
||||
void initSkinSiteSession().catch(handleError)
|
||||
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
|
||||
@@ -1249,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
|
||||
@@ -1274,42 +1292,7 @@ async function closeOnboardingSettings() {
|
||||
}
|
||||
|
||||
async function scheduleStartupDialogs() {
|
||||
if (!stateInitialized.value || privacyConsentPending.value || showOnboarding.value) return
|
||||
}
|
||||
|
||||
async function handlePrivacyConsentSaved(privacy: PrivacySettings) {
|
||||
privacyConsentPending.value = false
|
||||
if (onboardingSettings.value) {
|
||||
onboardingSettings.value.telemetry = privacy.telemetry
|
||||
onboardingSettings.value.discord_rpc = privacy.discord_rpc
|
||||
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
|
||||
}
|
||||
if (!onboardingSettings.value?.onboarded) {
|
||||
startOnboarding('main')
|
||||
} else {
|
||||
await scheduleStartupDialogs()
|
||||
}
|
||||
}
|
||||
|
||||
async function previewPrivacyConsentModal() {
|
||||
try {
|
||||
const current = await getPrivacySettings()
|
||||
const privacy = await savePrivacySettings({
|
||||
telemetry: false,
|
||||
discord_rpc: current.discord_rpc,
|
||||
consent_version: 0,
|
||||
})
|
||||
privacyConsentPending.value = true
|
||||
if (onboardingSettings.value) {
|
||||
onboardingSettings.value.telemetry = privacy.telemetry
|
||||
onboardingSettings.value.discord_rpc = privacy.discord_rpc
|
||||
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
|
||||
}
|
||||
await nextTick()
|
||||
privacyConsentModal.value?.show(privacy)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
if (!stateInitialized.value || showOnboarding.value) return
|
||||
}
|
||||
|
||||
provide('replayOnboarding', replayOnboarding)
|
||||
@@ -1320,7 +1303,9 @@ provide(
|
||||
)
|
||||
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
|
||||
provide('showLauncherPopup', (_request: unknown) => {})
|
||||
provide('previewPrivacyConsentModal', previewPrivacyConsentModal)
|
||||
provide('showStartupAnnouncement', () => startupAnnouncementModal.value?.show())
|
||||
|
||||
installUiScale()
|
||||
|
||||
const stateFailed = ref(false)
|
||||
stateInitialization
|
||||
@@ -1429,9 +1414,6 @@ loading.setEnabled(false)
|
||||
let initialLoadToken = loading.begin()
|
||||
let routerToken = null
|
||||
let suspenseToken = null
|
||||
let lastDiscordActivity = null
|
||||
let discordActivityUpdate = Promise.resolve()
|
||||
|
||||
let suspensePending = false
|
||||
|
||||
const sidebarOverlayScrollbarsOptions = Object.freeze({
|
||||
@@ -1447,31 +1429,11 @@ router.beforeEach(() => {
|
||||
routerToken = loading.begin()
|
||||
})
|
||||
|
||||
function syncDiscordActivity(to: RouteLocationNormalizedLoaded) {
|
||||
const activity =
|
||||
typeof to.meta.discordActivity === 'string' ? to.meta.discordActivity : 'Idling...'
|
||||
if (activity === lastDiscordActivity) return
|
||||
|
||||
lastDiscordActivity = activity
|
||||
discordActivityUpdate = discordActivityUpdate
|
||||
.then(() => set_discord_activity(activity))
|
||||
.catch((error) => {
|
||||
if (lastDiscordActivity === activity) lastDiscordActivity = null
|
||||
console.error('Failed to update Discord activity', error)
|
||||
})
|
||||
}
|
||||
|
||||
router.afterEach((to, from, failure) => {
|
||||
hideAllPoppers()
|
||||
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
|
||||
trackEvent('PageView', {
|
||||
path: to.path,
|
||||
fromPath: from.path,
|
||||
failed: failure,
|
||||
})
|
||||
if (!failure) {
|
||||
void directLinkSync?.()
|
||||
if (stateInitialized.value) syncDiscordActivity(to)
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!suspensePending && stateInitialized.value) {
|
||||
@@ -1508,7 +1470,6 @@ watch(
|
||||
stateInitialized,
|
||||
(ready) => {
|
||||
if (ready) {
|
||||
syncDiscordActivity(router.currentRoute.value)
|
||||
if (initialLoadToken) {
|
||||
loading.end(initialLoadToken)
|
||||
initialLoadToken = null
|
||||
@@ -1614,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,
|
||||
@@ -1624,7 +1612,6 @@ const dropImport = useDropImport({
|
||||
onSkinsPage,
|
||||
onSchematicWorkshopPage,
|
||||
isSchematicFile,
|
||||
trackEvent,
|
||||
router,
|
||||
})
|
||||
|
||||
@@ -1783,6 +1770,7 @@ onMounted(() => {
|
||||
setServerUpdateToPlayModal(updateToPlayModal.value)
|
||||
void (async () => {
|
||||
try {
|
||||
await stateInitialization
|
||||
const ready = await invoke<{
|
||||
pending_crashes: { instance_id: string; uuid: string }[]
|
||||
pending_commands: Parameters<typeof handleCommand>[0][]
|
||||
@@ -1836,13 +1824,14 @@ async function handleCommand(e) {
|
||||
} else {
|
||||
await install_create_modpack_instance(location).catch(handleError)
|
||||
}
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
}
|
||||
} else if (e.event === 'LaunchInstance') {
|
||||
const instance = await getInstance(e.id).catch(() => null)
|
||||
const handleLaunchCommandError = async (launchError) => {
|
||||
// Navigating to the skin-site login to pick a player is a deliberate
|
||||
// user action, not a launch failure: stay silent and let the user
|
||||
// re-trigger the launch after signing in.
|
||||
if (launchError instanceof PlayerSelectionNavigatedAwayError) return
|
||||
const handled =
|
||||
(await minecraftCrashModal.value?.handleLaunchError(launchError, {
|
||||
instance_id: e.id,
|
||||
@@ -2010,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.`
|
||||
@@ -2054,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
|
||||
@@ -2062,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')
|
||||
@@ -2074,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'
|
||||
}
|
||||
@@ -2104,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`)
|
||||
@@ -2112,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2239,7 +2247,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AnniversaryCelebration />
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<InstancePlayerModal v-if="stateInitialized" />
|
||||
<StartupAnnouncementModal ref="startupAnnouncementModal" />
|
||||
<RemoteAnnouncements :ready="stateInitialized && !showOnboarding && !stateFailed" />
|
||||
<div id="teleports"></div>
|
||||
<div
|
||||
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
||||
@@ -2321,13 +2333,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.starlightSkin)"
|
||||
data-onboarding-id="nav-starlight-skin"
|
||||
to="/starlight-skin"
|
||||
>
|
||||
<GlobeIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.library)"
|
||||
data-onboarding-id="nav-library"
|
||||
@@ -2342,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"
|
||||
@@ -2509,9 +2531,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
{{ formatMessage(messages.authUnreachableBody) }}
|
||||
</Admonition>
|
||||
<div class="page-transition-grid grid min-h-full">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<RouterView v-slot="{ Component, route: currentRoute }">
|
||||
<Transition name="page-slide" :css="themeStore.getFeatureFlag('page_transitions')">
|
||||
<div v-if="Component" :key="getPageTransitionKey(route)" class="page-transition-layer">
|
||||
<div
|
||||
v-if="Component"
|
||||
:key="getPageTransitionKey(currentRoute)"
|
||||
class="page-transition-layer"
|
||||
>
|
||||
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
|
||||
<component :is="Component"></component>
|
||||
</Suspense>
|
||||
@@ -2551,9 +2577,12 @@ 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.playingAs) }}
|
||||
{{ formatMessage(messages.userInformation) }}
|
||||
</h3>
|
||||
<suspense>
|
||||
<AccountsCard ref="accounts" />
|
||||
@@ -2575,9 +2604,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
:on-error-action="exportNotificationErrorLogs"
|
||||
:error-action-label="formatMessage(messages.exportErrorLogs)"
|
||||
/>
|
||||
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
|
||||
<MinecraftCrashModal v-if="stateInitialized" ref="minecraftCrashModal" @error="handleError" />
|
||||
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
|
||||
<PrivacyConsentModal ref="privacyConsentModal" @saved="handlePrivacyConsentSaved" />
|
||||
<NewModal
|
||||
ref="closeChoiceModal"
|
||||
:header="formatMessage(messages.closeLauncherTitle)"
|
||||
@@ -2664,6 +2692,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@install="handleContentInstallModpackInstall"
|
||||
@cancel="handleContentInstallModpackInstallCancel"
|
||||
/>
|
||||
<TaggedModDownloadsModal v-if="stateInitialized" />
|
||||
<CurseForgeManualDownloadsModal
|
||||
ref="contentInstallCurseForgeManualDownloadsModal"
|
||||
@view-instance="handleContentInstallModpackDuplicateGoToInstance"
|
||||
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 835 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,31 @@
|
||||
# 凋灵局部清理素材
|
||||
|
||||
## 横梁删除修正(2026-09-15)
|
||||
|
||||
- 素材:[wither-beams-removed.png](./wither-beams-removed.png),2172 × 724。
|
||||
- 方式:内置 imagegen,局部对象删除;输入为上一版清理图与用户标注的两处横梁。
|
||||
- 仅使用左右横梁及其侧面的轮廓区域,补回露出的背景;其他位置仍使用之前的清晰素材。对应的横梁护盾面同时删除。
|
||||
- 不重编码既有母图,不使用整张新生成图替换画面,不增加模糊或羽化。
|
||||
|
||||
最终提示词:
|
||||
|
||||
Use case: precise-object-edit.
|
||||
Image 1 is the edit target: the full 3:1 Minecraft night scene. Image 2 is a location guide only, with red rectangles identifying two unwanted horizontal shoulder beams. Do NOT copy the UI, red marks, sword, star, clouds or alternate appearance from image 2.
|
||||
Remove the TWO long thin horizontal brown/black bars running out sideways from the fallen Wither's upper chest to its left and right side skulls. In image 1 at normalized 2048x683 coordinates these are approximately left x978–1140 y467–527 and right x1280–1405 y487–535. ERASE these bars completely, including their top face and side face; show the existing dark blue-gray ground/background visible through the new open gaps. The left and right skulls must stay in their exact positions on the ground, separated visually from the chest in these gaps. Do not invent substitute bars, short stumps, connecting rods, upright shoulder bones, debris, ribs or decorations in these two gaps.
|
||||
Preserve all other pixels/geometry/composition: the three blocky skulls, central neck, broken rib cage, spine, player's exact pose and clothes, moon, landscape, frame edges, lighting and colors. In particular do not remove or redraw the existing curved/segmented chest ribs below the two bars. Precisely restore the small exposed background behind removed bars, with crisp square voxel texture. No blur, smoothing, feathering, depth of field, re-rendering of the whole image, glow or haze. Keep the original full 3:1 frame and exact object registration, at 2172x724 if possible. This is a tiny local object removal, not a restyle.
|
||||
|
||||
## 上一版肩部凸出结构清理
|
||||
|
||||
- 素材:[wither-shoulder-cleanup.png](./wither-shoulder-cleanup.png),2172 × 724。
|
||||
- 生成方式:内置 imagegen 图像编辑工具。
|
||||
- 编辑目标:`victory-clean.jpg`;辅助参考为用户标红的两处肩部异常结构及 Minecraft 凋灵截图。
|
||||
- 实际使用:仅将两处肩部异常结构的清理结果合成到清晰母图。整张生成图不作为背景使用,三个头、肋骨、角色和环境保留原有素材。
|
||||
- 动画在独立图层中绘制;护盾几何记录在 `wither-shield.ts`,坐标以 2048 × 682⅔ 的场景空间表示。
|
||||
|
||||
## 生成时使用的提示词
|
||||
|
||||
Use case: precise-object-edit. Asset type: a sharp replacement Wither plate for an existing Minecraft cinematic animated banner. Image 1 is the EDIT TARGET: retain its exact 3:1 panoramic framing and camera, keep every other object and the character unchanged. Image 2 is ONLY an annotation reference: its red rectangles identify two erroneous upright slabs/extra structures on the Wither's shoulders which must be removed; do not draw any red marks. Image 3 is ONLY a vanilla Minecraft Wither anatomy reference; use its simple three-headed skeletal structure, not its standing pose or blue shield.
|
||||
|
||||
Regenerate ONLY the fallen Wither in Image 1 cleanly, with crisp straight voxel edges, dark charcoal/black nether bone material and clean low-resolution Minecraft pixel textures. Exactly three cuboid skulls: near-left skull under the player's raised boot, central skull farther back facing upward, right skull at the right, preserving their existing centers, scale, screen orientation and silhouette as closely as possible. Retain the existing supine defeated pose, clean horizontal shoulder crossbar linking the three heads, a compact rib cage with separated squared ribs, and the central spine receding into the foreground. Remove the two upright paddle-like shoulder projections indicated by the red boxes, all extra heads, all spikes/horns, all gold mechanisms, odd rubble embedded in the body, noisy invented decorations and duplicate bones. The chest has one coherent small broken sternum opening centered exactly where the animated sword will enter (about 56% across and 72% down the full panorama). Preserve visible negative space between ribs, without random detached bits. This is a defeated non-human Minecraft boss, no blood or gore.
|
||||
|
||||
Keep the player's existing foot resting on the near-left skull at precisely the same height and location. Keep the character, face, volumetric brown hair, yellow clothing, armor, hands and boots absolutely unchanged. Keep the sky, moon, trees, terrain and all other regions unchanged and sharp. Do NOT add a sword or nether star: these already exist as separate animated layers. Do NOT paint any blue shielding, electricity, glow halos or particles onto the Wither; those will be separate precisely aligned animated layers. Match existing cool night illumination and subtle warm gold light on the top-facing bone planes. Reconstruct only the small pieces of ground/sky revealed by removing the two wrong shoulder slabs. No blur, no painterly smoothing, no depth-of-field blur, no sharpening halos. Output a single full panoramic 3:1 edited plate, same framing as Image 1, at the highest available resolution.
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<title>幸运四叶草</title>
|
||||
<path
|
||||
d="M33 34C39.5 38.5 44.5 46 47 56"
|
||||
stroke="var(--clover-edge, #16883F)"
|
||||
stroke-width="5.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
|
||||
fill="var(--clover-leaf, #42C95A)"
|
||||
stroke="var(--clover-edge, #16883F)"
|
||||
stroke-width="2.2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
|
||||
transform="rotate(90 32 32)"
|
||||
fill="var(--clover-leaf, #42C95A)"
|
||||
stroke="var(--clover-edge, #16883F)"
|
||||
stroke-width="2.2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
|
||||
transform="rotate(180 32 32)"
|
||||
fill="var(--clover-leaf, #42C95A)"
|
||||
stroke="var(--clover-edge, #16883F)"
|
||||
stroke-width="2.2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
|
||||
transform="rotate(270 32 32)"
|
||||
fill="var(--clover-leaf, #42C95A)"
|
||||
stroke="var(--clover-edge, #16883F)"
|
||||
stroke-width="2.2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="32" cy="32" r="3.7" fill="var(--clover-edge, #16883F)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 740 KiB |
@@ -28,7 +28,6 @@ import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { kill, remove, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process.js'
|
||||
@@ -43,6 +42,7 @@ const { offline } = useNetworkStatus()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
|
||||
const messages = defineMessages({
|
||||
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
|
||||
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
|
||||
viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' },
|
||||
duplicateInstance: {
|
||||
@@ -158,17 +158,9 @@ const handleOptionsClick = async (args) => {
|
||||
})
|
||||
if (!handled) handleSevereError(err, { instanceId: args.item.id })
|
||||
})
|
||||
trackEvent('InstanceStart', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
})
|
||||
break
|
||||
case 'stop':
|
||||
await kill(args.item.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
})
|
||||
break
|
||||
case 'add_content':
|
||||
await router.push({
|
||||
@@ -274,7 +266,12 @@ onUnmounted(() => {
|
||||
@delete="deleteInstance"
|
||||
/>
|
||||
<div ref="rowContainer" class="flex flex-col gap-4">
|
||||
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row flex flex-col items-start overflow-hidden w-full min-w-full">
|
||||
<div
|
||||
v-for="row in actualInstances"
|
||||
ref="rows"
|
||||
:key="row.label"
|
||||
class="row flex flex-col items-start overflow-hidden w-full min-w-full"
|
||||
>
|
||||
<HeadingLink class="mt-1" :to="row.route">
|
||||
{{ row.label }}
|
||||
</HeadingLink>
|
||||
@@ -316,7 +313,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stopAll) }} </template>
|
||||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||||
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
|
||||
<template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </template>
|
||||
|
||||
@@ -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>
|
||||
@@ -18,7 +18,6 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
type DailyPlaytime,
|
||||
type DailyPlaytimeEntry,
|
||||
@@ -200,11 +199,6 @@ function selectDay(dateKey: string) {
|
||||
async function playInstance(instance: GameInstance) {
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeCalendar',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@@ -216,11 +210,6 @@ async function playInstance(instance: GameInstance) {
|
||||
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeCalendar',
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { loading_listener } from '@/helpers/events'
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -15,6 +15,7 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
interface LoadingEventPayload {
|
||||
total?: number | null
|
||||
event: LoadingBar['bar_type']
|
||||
loader_uuid: string
|
||||
fraction: number | null
|
||||
@@ -22,6 +23,7 @@ interface LoadingEventPayload {
|
||||
}
|
||||
|
||||
interface LaunchProgressItem {
|
||||
waiting: boolean
|
||||
key: string
|
||||
message: string
|
||||
fraction: number | null
|
||||
@@ -29,6 +31,7 @@ interface LaunchProgressItem {
|
||||
|
||||
// 启动相关(以及准备)阶段会发的 loading 类型;安装/下载也一并显示,便于用户看到进度
|
||||
const LAUNCH_BAR_TYPES = new Set([
|
||||
'hosted_pack_sync',
|
||||
'minecraft_download',
|
||||
'instance_update',
|
||||
'zip_extract',
|
||||
@@ -54,6 +57,7 @@ function applyEvent(payload: LoadingEventPayload) {
|
||||
if (!isVisible(payload.event)) return
|
||||
|
||||
activeMap.set(payload.loader_uuid, {
|
||||
waiting: payload.total === 0,
|
||||
key: payload.loader_uuid,
|
||||
message: payload.message,
|
||||
fraction: payload.fraction,
|
||||
@@ -64,13 +68,28 @@ function applyEvent(payload: LoadingEventPayload) {
|
||||
const hasProgress = computed(() => progressItems.value.length > 0)
|
||||
|
||||
function percent(item: LaunchProgressItem): string {
|
||||
if (item.fraction == null || !Number.isFinite(item.fraction)) return ''
|
||||
if (item.waiting || item.fraction == null || !Number.isFinite(item.fraction)) return ''
|
||||
return `${Math.round(Math.max(0, Math.min(1, item.fraction)) * 100)}%`
|
||||
}
|
||||
|
||||
let initializing = true
|
||||
const buffered: LoadingEventPayload[] = []
|
||||
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
|
||||
applyEvent(payload)
|
||||
if (initializing) buffered.push(payload)
|
||||
else applyEvent(payload)
|
||||
})
|
||||
const bars = await progress_bars_list().catch(() => ({}))
|
||||
for (const bar of Object.values(bars)) {
|
||||
applyEvent({
|
||||
event: bar.bar_type,
|
||||
loader_uuid: String(bar.loading_bar_uuid),
|
||||
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
|
||||
total: bar.total,
|
||||
message: bar.message ?? '',
|
||||
})
|
||||
}
|
||||
initializing = false
|
||||
for (const payload of buffered) applyEvent(payload)
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenLoading?.()
|
||||
@@ -96,12 +115,7 @@ onUnmounted(() => {
|
||||
{{ percent(item) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-surface-4">
|
||||
<div
|
||||
class="h-full rounded-full bg-brand transition-[width] duration-200"
|
||||
:style="{ width: percent(item) || '100%' }"
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar :progress="item.fraction ?? 0" :waiting="item.waiting" full-width />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -1,35 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import LuckyCloverIcon from '@/assets/icons/lucky-clover.svg'
|
||||
import {
|
||||
openSkinSiteLogin,
|
||||
requestSkinSiteLuck,
|
||||
skinSiteUser,
|
||||
} from '@/composables/skin-site-session'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
widgetTitle: { id: 'app.home.skin-site.title', defaultMessage: 'StarLight skin site' },
|
||||
luckyIndex: { id: 'app.home.skin-site.lucky-index', defaultMessage: 'Lucky index' },
|
||||
jump: { id: 'app.home.skin-site.jump', defaultMessage: 'Jump to StarLight skin site' },
|
||||
widgetTitle: { id: 'app.home.luck.title', defaultMessage: 'Daily luck index' },
|
||||
getLuck: { id: 'app.home.luck.get', defaultMessage: 'Get with one click' },
|
||||
loginToGet: { id: 'app.home.luck.login-to-get', defaultMessage: 'Sign in to get it' },
|
||||
loading: { id: 'app.home.luck.loading', defaultMessage: 'Asking the little sprite...' },
|
||||
error: {
|
||||
id: 'app.home.luck.error',
|
||||
defaultMessage: 'The little sprite did not answer. Please try again later.',
|
||||
},
|
||||
outOf: { id: 'app.home.luck.out-of', defaultMessage: 'out of 100' },
|
||||
})
|
||||
|
||||
const SKIN_PROFILE_URL = 'https://skin.starlight.cool/profile'
|
||||
const SKIN_HOME_URL = 'https://skin.starlight.cool/'
|
||||
const luck = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const waitingForLogin = ref(false)
|
||||
const signedIn = computed(() => Boolean(skinSiteUser.value))
|
||||
|
||||
async function openProfile() {
|
||||
async function fetchLuck() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
await openUrl(SKIN_PROFILE_URL)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
luck.value = await requestSkinSiteLuck()
|
||||
} catch {
|
||||
failed.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openSkinSite() {
|
||||
try {
|
||||
await openUrl(SKIN_HOME_URL)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
function handlePrimaryAction() {
|
||||
if (signedIn.value) {
|
||||
void fetchLuck()
|
||||
return
|
||||
}
|
||||
waitingForLogin.value = true
|
||||
openSkinSiteLogin()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => skinSiteUser.value?.uuid,
|
||||
(uuid, previousUuid) => {
|
||||
if (uuid === previousUuid) return
|
||||
luck.value = null
|
||||
failed.value = false
|
||||
if (uuid && waitingForLogin.value) {
|
||||
waitingForLogin.value = false
|
||||
void fetchLuck()
|
||||
} else if (!uuid) {
|
||||
waitingForLogin.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,32 +72,72 @@ async function openSkinSite() {
|
||||
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<LuckyCloverIcon class="lucky-clover size-5 shrink-0" aria-hidden="true" />
|
||||
<h2 class="m-0 truncate text-lg">
|
||||
{{ formatMessage(messages.widgetTitle) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center justify-between rounded-lg border border-[--brand-gradient-border] bg-transparent px-3 py-2 text-left transition-colors hover:bg-button-bg"
|
||||
@click="openProfile"
|
||||
>
|
||||
<span class="text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(messages.luckyIndex) }}
|
||||
</span>
|
||||
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="loading" class="luck-feedback" role="status" aria-live="polite">
|
||||
<LuckyCloverIcon
|
||||
class="lucky-clover lucky-clover-loading size-7 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ formatMessage(messages.loading) }}</span>
|
||||
</div>
|
||||
<div v-else-if="luck !== null" class="luck-feedback" aria-live="polite">
|
||||
<strong class="text-3xl leading-none text-contrast">{{ luck }}</strong>
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.outOf) }}</span>
|
||||
</div>
|
||||
<p v-else-if="failed" class="m-0 text-sm leading-5 text-secondary" role="alert">
|
||||
{{ formatMessage(messages.error) }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-[--brand-gradient] px-3 py-2 font-semibold text-[var(--color-contrast)] transition-opacity hover:opacity-90"
|
||||
@click="openSkinSite"
|
||||
>
|
||||
<ExternalIcon class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{{ formatMessage(messages.jump) }}</span>
|
||||
</button>
|
||||
<ButtonStyled color="brand" class="w-full">
|
||||
<button type="button" class="w-full" :disabled="loading" @click="handlePrimaryAction">
|
||||
{{ formatMessage(signedIn ? messages.getLuck : messages.loginToGet) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lucky-clover {
|
||||
--clover-leaf: color-mix(in srgb, #42c95a 88%, var(--color-brand));
|
||||
--clover-edge: color-mix(in srgb, #16883f 88%, var(--color-brand));
|
||||
}
|
||||
|
||||
.luck-feedback {
|
||||
display: flex;
|
||||
min-height: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lucky-clover-loading {
|
||||
transform-origin: center;
|
||||
animation: clover-wait 850ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes clover-wait {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(1px) rotate(-5deg) scale(0.96);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-2px) rotate(6deg) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lucky-clover-loading {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -25,10 +25,10 @@ import HomeGreeting from '@/components/home/HomeGreeting.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
@@ -51,6 +51,8 @@ const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const { offline } = useNetworkStatus()
|
||||
|
||||
const messages = defineMessages({
|
||||
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
|
||||
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
|
||||
chooseInstance: {
|
||||
id: 'app.home.minimal.choose-instance',
|
||||
defaultMessage: 'Choose instance',
|
||||
@@ -89,14 +91,17 @@ const selectedInstance = computed(() =>
|
||||
props.instances.find((instance) => instance.id === props.selectedInstanceId),
|
||||
)
|
||||
const running = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentEvent = ref<string | null>(null)
|
||||
const installingId = ref<string>()
|
||||
const installed = computed(() => selectedInstance.value?.install_stage === 'installed')
|
||||
const installing = computed(
|
||||
() => selectedInstance.value?.install_stage.includes('installing') ?? false,
|
||||
)
|
||||
const busy = computed(
|
||||
() => loading.value || installing.value || (currentEvent.value === 'launched' && !running.value),
|
||||
() =>
|
||||
!!selectedInstance.value &&
|
||||
(isInstanceLaunching(selectedInstance.value.id) ||
|
||||
installingId.value === selectedInstance.value.id ||
|
||||
installing.value),
|
||||
)
|
||||
|
||||
const lastPlayed = computed(() => {
|
||||
@@ -112,25 +117,19 @@ async function refreshProcessState() {
|
||||
return
|
||||
}
|
||||
|
||||
const processes = await get_by_instance_id(selectedInstance.value.id).catch((error) => {
|
||||
const id = selectedInstance.value.id
|
||||
const processes = await get_by_instance_id(id).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
running.value = processes.length > 0
|
||||
if (selectedInstance.value?.id === id) running.value = processes.length > 0
|
||||
}
|
||||
|
||||
async function playInstance() {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance) return
|
||||
|
||||
loading.value = true
|
||||
if (!instance || busy.value) return
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeMinimal',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@@ -138,7 +137,6 @@ async function playInstance() {
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
||||
} finally {
|
||||
loading.value = false
|
||||
await refreshProcessState()
|
||||
}
|
||||
}
|
||||
@@ -148,19 +146,14 @@ async function stopInstance() {
|
||||
if (!instance) return
|
||||
|
||||
await kill(instance.id).catch(handleError)
|
||||
running.value = false
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeMinimal',
|
||||
})
|
||||
await refreshProcessState()
|
||||
}
|
||||
|
||||
async function installInstance() {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance) return
|
||||
|
||||
loading.value = true
|
||||
installingId.value = instance.id
|
||||
try {
|
||||
if (
|
||||
instance.install_stage !== 'pack_installed' &&
|
||||
@@ -179,14 +172,14 @@ async function installInstance() {
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (installingId.value === instance.id) installingId.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selectedInstanceId,
|
||||
() => {
|
||||
currentEvent.value = null
|
||||
running.value = false
|
||||
void refreshProcessState()
|
||||
},
|
||||
)
|
||||
@@ -195,9 +188,7 @@ await refreshProcessState()
|
||||
|
||||
const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => {
|
||||
if (event.instance_id !== selectedInstance.value?.id) return
|
||||
currentEvent.value = event.event
|
||||
if (event.event === 'finished') running.value = false
|
||||
else void refreshProcessState()
|
||||
void refreshProcessState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -210,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">
|
||||
@@ -248,13 +239,17 @@ onUnmounted(() => {
|
||||
</router-link>
|
||||
|
||||
<div class="flex min-h-11 shrink-0 items-center justify-end gap-2">
|
||||
<ButtonStyled v-if="running" color="red" size="large">
|
||||
<button class="w-36 justify-center" @click="stopInstance">
|
||||
<ButtonStyled v-if="running" color="red" size="large" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stopAll)"
|
||||
:aria-label="formatMessage(messages.stopAll)"
|
||||
:disabled="busy"
|
||||
@click="stopInstance"
|
||||
>
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
<span class="truncate">{{ formatMessage(commonMessages.stopButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="busy" size="large">
|
||||
<ButtonStyled v-if="busy" size="large">
|
||||
<button class="w-36 justify-center" disabled>
|
||||
<SpinnerIcon class="animate-spin" aria-hidden="true" />
|
||||
<span class="truncate">
|
||||
@@ -267,7 +262,9 @@ onUnmounted(() => {
|
||||
<ButtonStyled v-else-if="installed" color="brand" size="large">
|
||||
<button class="w-36 justify-center" @click="playInstance">
|
||||
<PlayIcon class="translate-x-px" aria-hidden="true" />
|
||||
<span class="truncate">{{ formatMessage(commonMessages.playButton) }}</span>
|
||||
<span class="truncate">{{
|
||||
formatMessage(running ? messages.launchAnother : commonMessages.playButton)
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" size="large">
|
||||
|
||||
@@ -22,7 +22,6 @@ import { computed, ref } from 'vue'
|
||||
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
@@ -107,11 +106,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
|
||||
|
||||
try {
|
||||
await start_join_server(world.instance_id, world.address)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedServer',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@@ -125,11 +119,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
|
||||
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedServer',
|
||||
})
|
||||
}
|
||||
|
||||
async function unpinServer(world: ServerWorld & WorldWithInstance) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getHomeWidgetCardDensity, type HomeWidgetSize } from '@/components/home
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
@@ -73,11 +72,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
try {
|
||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||
playingWorldKey.value = key
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@@ -92,11 +86,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
async function playInstance(instance: GameInstance) {
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@@ -109,11 +98,6 @@ async function playInstance(instance: GameInstance) {
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
playingWorldKey.value = null
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedWorld',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtim
|
||||
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
@@ -37,6 +36,7 @@ const props = withDefaults(
|
||||
limit?: HomeRecentLimit
|
||||
}>(),
|
||||
{
|
||||
dashboardSize: null,
|
||||
limit: HOME_RECENT_DEFAULT_LIMIT,
|
||||
},
|
||||
)
|
||||
@@ -117,11 +117,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||
}
|
||||
playingWorldKey.value = key
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeRecentWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@@ -136,11 +131,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
async function playInstance(instance: GameInstance) {
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeRecentWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
@@ -153,20 +143,21 @@ async function playInstance(instance: GameInstance) {
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
playingWorldKey.value = null
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeRecentWorld',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
|
||||
<section
|
||||
class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3"
|
||||
:data-size="dashboardSize"
|
||||
>
|
||||
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
|
||||
<h2>{{ formatMessage(messages.recentTitle) }}</h2>
|
||||
</div>
|
||||
<div v-if="recentItems.length > 0" class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1">
|
||||
<div
|
||||
v-if="recentItems.length > 0"
|
||||
class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1"
|
||||
>
|
||||
<template
|
||||
v-for="item in recentItems"
|
||||
:key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`"
|
||||
|
||||
@@ -28,7 +28,6 @@ import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
@@ -205,11 +204,6 @@ async function playInstance(targetInstance: GameInstance) {
|
||||
starting.value = true
|
||||
try {
|
||||
await run(targetInstance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: targetInstance.loader,
|
||||
game_version: targetInstance.game_version,
|
||||
source: 'HomeInstanceWidget',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: targetInstance.id,
|
||||
@@ -230,11 +224,6 @@ async function playWorld() {
|
||||
} else {
|
||||
await start_join_singleplayer_world(instance.value.id, world.value.path)
|
||||
}
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: 'HomeShortcutWidget',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.value.id,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
max-width="560px"
|
||||
:on-hide="handleHide"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.gameDirLabel) }}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<StyledInput
|
||||
class="flex-1"
|
||||
:model-value="selectedPath"
|
||||
readonly
|
||||
:placeholder="defaultPath || formatMessage(messages.noSelection)"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="browse">
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(messages.browse) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<button
|
||||
v-if="defaultPath && selectedPath !== defaultPath"
|
||||
type="button"
|
||||
class="self-start text-sm text-brand underline decoration-transparent underline-offset-2 transition-colors hover:decoration-current"
|
||||
@click="selectedPath = defaultPath"
|
||||
>
|
||||
{{ formatMessage(messages.resetDefault) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="previewPath" class="m-0 text-sm text-secondary break-all">
|
||||
{{ formatMessage(messages.preview, { path: previewPath }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="!selectedPath" @click="handleConfirm">
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.confirm) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectFilePicker,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', gameDirRoot: string): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const filePicker = injectFilePicker()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const defaultPath = ref('')
|
||||
const selectedPath = ref('')
|
||||
const accepted = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.hosted-install.game-dir.header',
|
||||
defaultMessage: 'Choose game directory',
|
||||
},
|
||||
description: {
|
||||
id: 'app.hosted-install.game-dir.description',
|
||||
defaultMessage:
|
||||
'StarLight instance data (mods, saves, configs, resource packs) is stored in an external game directory. Pick a root folder — the modpack gets its own subfolder inside it.',
|
||||
},
|
||||
gameDirLabel: {
|
||||
id: 'app.hosted-install.game-dir.label',
|
||||
defaultMessage: 'Game directory root',
|
||||
},
|
||||
browse: {
|
||||
id: 'app.hosted-install.game-dir.browse',
|
||||
defaultMessage: 'Browse',
|
||||
},
|
||||
noSelection: {
|
||||
id: 'app.hosted-install.game-dir.no-selection',
|
||||
defaultMessage: 'No folder selected',
|
||||
},
|
||||
resetDefault: {
|
||||
id: 'app.hosted-install.game-dir.reset-default',
|
||||
defaultMessage: 'Use default location',
|
||||
},
|
||||
preview: {
|
||||
id: 'app.hosted-install.game-dir.preview',
|
||||
defaultMessage: 'Game files will be installed to: {path}',
|
||||
},
|
||||
confirm: {
|
||||
id: 'app.hosted-install.game-dir.confirm',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
})
|
||||
|
||||
const previewPath = computed(() => {
|
||||
const base = (selectedPath.value ?? '').replace(/[\\/]+$/, '')
|
||||
return base ? `${base}/<pack name>` : ''
|
||||
})
|
||||
|
||||
async function show() {
|
||||
if (!defaultPath.value) {
|
||||
defaultPath.value = await invoke<string>('get_launcher_root_dir').catch(() => '')
|
||||
}
|
||||
if (!selectedPath.value) selectedPath.value = defaultPath.value
|
||||
accepted.value = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function browse() {
|
||||
const picked = await filePicker.pickFolder?.()
|
||||
if (picked?.path) selectedPath.value = picked.path
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
accepted.value = true
|
||||
modal.value?.hide()
|
||||
emit('confirm', selectedPath.value)
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
if (!accepted.value) emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<InstanceModeSettings :instance-id="instanceId" :disabled="syncing" class="mb-5" />
|
||||
<div v-if="modeQuery.data.value === 'local'" class="flex flex-col gap-3">
|
||||
<h2 class="m-0">{{ formatMessage(messages.localTitle) }}</h2>
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.localPacks) }}</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<ButtonStyled
|
||||
><button type="button" @click="router.push('/browse/modpack')">
|
||||
{{ formatMessage(messages.browse) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled
|
||||
><button type="button" @click="importLocal">
|
||||
{{ formatMessage(messages.importLocal) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="modeQuery.data.value === 'starlight'">
|
||||
<section class="flex flex-col gap-4" :aria-busy="loading || syncing">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<p>{{ formatMessage(messages.description) }}</p>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
><button type="button" :disabled="loading || syncing" @click="load">
|
||||
{{ formatMessage(messages.refresh) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
<p v-if="loading" role="status">{{ formatMessage(messages.loading) }}</p>
|
||||
<p v-if="error" role="alert" class="text-red">{{ error }}</p>
|
||||
<p v-if="binding">
|
||||
{{
|
||||
formatMessage(messages.bound, {
|
||||
name: binding.publication.manifest.name,
|
||||
version: binding.publication.manifest.version,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<HostedPackProgress :instance-id="instanceId" :active="syncing" />
|
||||
<div v-if="result" role="status" class="rounded-xl bg-bg-raised p-4">
|
||||
<p>
|
||||
{{
|
||||
formatMessage(messages.complete, {
|
||||
version: result.version,
|
||||
count: result.changedFiles,
|
||||
size: (result.downloadedBytes / 1048576).toFixed(2),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<details v-if="result.preservedFiles.length">
|
||||
<summary>
|
||||
{{ formatMessage(messages.preserved, { count: result.preservedFiles.length }) }}
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="path in result.preservedFiles" :key="path">{{ path }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-if="pack"
|
||||
class="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-bg-raised p-4"
|
||||
>
|
||||
<div>
|
||||
<h3 class="m-0">{{ pack.manifest.name }}</h3>
|
||||
<p class="mb-0">
|
||||
{{ pack.manifest.version }} · Minecraft {{ pack.manifest.runtime.gameVersion }} ·
|
||||
{{ pack.manifest.runtime.loader }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled color="brand"
|
||||
><button type="button" :disabled="!ready || syncing || loading" @click="sync">
|
||||
{{ formatMessage(binding ? messages.update : messages.install) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import HostedPackProgress from '@/components/instance/HostedPackProgress.vue'
|
||||
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
|
||||
import { markHostedCreationCompleted } from '@/composables/useHostedCreation'
|
||||
import { useHostedSync } from '@/composables/useHostedSync'
|
||||
import { useInstanceMode } from '@/composables/useInstanceMode'
|
||||
import {
|
||||
type HostedBinding,
|
||||
hostedBinding,
|
||||
hostedDefault,
|
||||
type HostedPublication,
|
||||
} from '@/helpers/hosted-packs'
|
||||
import { injectDownloadManager } from '@/providers/download-manager'
|
||||
const props = defineProps<{ instanceId: string }>()
|
||||
const modeQuery = useInstanceMode(() => props.instanceId)
|
||||
const router = useRouter()
|
||||
const showCreation = inject<(options: { skipSetupType: boolean; initialMode: 'import' }) => void>(
|
||||
'showCreationModalWithOptions',
|
||||
)
|
||||
function importLocal() {
|
||||
showCreation?.({ skipSetupType: true, initialMode: 'import' })
|
||||
}
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
localTitle: { id: 'app.instance-mode.local-packs-title', defaultMessage: 'Local modpacks' },
|
||||
localPacks: {
|
||||
id: 'app.instance-mode.local-packs',
|
||||
defaultMessage:
|
||||
'Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized.',
|
||||
},
|
||||
browse: { id: 'app.instance-mode.browse', defaultMessage: 'Browse modpacks' },
|
||||
importLocal: { id: 'app.instance-mode.import', defaultMessage: 'Import as a local instance' },
|
||||
title: { id: 'app.hosted-packs.title', defaultMessage: 'Server-managed modpack' },
|
||||
description: {
|
||||
id: 'app.hosted-packs.description',
|
||||
defaultMessage:
|
||||
'The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required.',
|
||||
},
|
||||
refresh: { id: 'app.hosted-packs.refresh', defaultMessage: 'Refresh' },
|
||||
loading: { id: 'app.hosted-packs.loading', defaultMessage: 'Loading published modpacks…' },
|
||||
bound: { id: 'app.hosted-packs.bound', defaultMessage: 'Installed: {name} · {version}' },
|
||||
syncing: {
|
||||
id: 'app.hosted-packs.syncing',
|
||||
defaultMessage: 'Comparing files and synchronizing changes…',
|
||||
},
|
||||
complete: {
|
||||
id: 'app.hosted-packs.complete',
|
||||
defaultMessage: 'Synced to {version}. Changed {count} files; downloaded {size} MiB.',
|
||||
},
|
||||
preserved: {
|
||||
id: 'app.hosted-packs.preserved',
|
||||
defaultMessage: 'Preserved {count} locally modified or personal files',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.hosted-packs.empty',
|
||||
defaultMessage: 'No modpacks have been approved for publication yet.',
|
||||
},
|
||||
update: { id: 'app.hosted-packs.update', defaultMessage: 'Synchronize now' },
|
||||
install: { id: 'app.hosted-packs.install', defaultMessage: 'Retry automatic installation' },
|
||||
})
|
||||
const pack = ref<HostedPublication | null>(null)
|
||||
const binding = ref<HostedBinding | null>(null)
|
||||
const task = useHostedSync(() => props.instanceId)
|
||||
const result = task.result
|
||||
const manager = injectDownloadManager()
|
||||
const loading = ref(false)
|
||||
const syncing = computed(
|
||||
() =>
|
||||
task.busy.value ||
|
||||
manager.legacyDownloads.value.some(
|
||||
(bar) =>
|
||||
bar.bar_type?.type === 'hosted_pack_sync' &&
|
||||
bar.bar_type.instance_id === props.instanceId &&
|
||||
!bar.bar_type.error,
|
||||
),
|
||||
)
|
||||
const ready = ref(false)
|
||||
const loadError = ref('')
|
||||
const error = computed(() => loadError.value || task.error.value)
|
||||
let generation = 0
|
||||
async function load() {
|
||||
if (modeQuery.data.value !== 'starlight') return
|
||||
const current = ++generation
|
||||
const instanceId = props.instanceId
|
||||
loading.value = true
|
||||
ready.value = false
|
||||
loadError.value = ''
|
||||
try {
|
||||
const [official, installed] = await Promise.all([hostedDefault(), hostedBinding(instanceId)])
|
||||
if (current !== generation) return
|
||||
pack.value = official
|
||||
binding.value = installed
|
||||
ready.value = true
|
||||
} catch (cause) {
|
||||
if (current === generation) loadError.value = String(cause)
|
||||
} finally {
|
||||
if (current === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
async function sync() {
|
||||
if (syncing.value || !ready.value || modeQuery.data.value !== 'starlight') return
|
||||
loadError.value = ''
|
||||
const result = await task.sync()
|
||||
if (result) markHostedCreationCompleted(props.instanceId)
|
||||
}
|
||||
watch(syncing, (busy, wasBusy) => {
|
||||
if (!busy && wasBusy) void load()
|
||||
})
|
||||
watch(
|
||||
() => [props.instanceId, modeQuery.data.value] as const,
|
||||
() => {
|
||||
generation++
|
||||
loading.value = false
|
||||
ready.value = false
|
||||
loadError.value = ''
|
||||
pack.value = null
|
||||
binding.value = null
|
||||
void load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { injectDownloadManager } from '@/providers/download-manager'
|
||||
|
||||
const props = defineProps<{ instanceId?: string; active?: boolean }>()
|
||||
const manager = injectDownloadManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
preparing: {
|
||||
id: 'app.hosted-packs.progress.preparing',
|
||||
defaultMessage: 'Preparing modpack installation…',
|
||||
},
|
||||
downloads: { id: 'app.hosted-packs.progress.downloads', defaultMessage: 'View downloads' },
|
||||
})
|
||||
const bar = computed(() =>
|
||||
manager.legacyDownloads.value.find(
|
||||
(item) =>
|
||||
props.instanceId &&
|
||||
item.bar_type?.type === 'hosted_pack_sync' &&
|
||||
!item.bar_type.error &&
|
||||
item.bar_type.instance_id === props.instanceId,
|
||||
),
|
||||
)
|
||||
const waiting = computed(() => !bar.value?.total)
|
||||
const current = computed(() =>
|
||||
Math.max(0, Math.min(bar.value?.current ?? 0, bar.value?.total ?? 0)),
|
||||
)
|
||||
const message = computed(() => bar.value?.message || formatMessage(messages.preparing))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="active || bar" class="flex min-w-0 flex-col gap-2" role="status">
|
||||
<ProgressBar
|
||||
:progress="current"
|
||||
:max="bar?.total || 1"
|
||||
:waiting="waiting"
|
||||
:label="message"
|
||||
label-class="min-w-0 break-all text-sm text-secondary"
|
||||
:show-progress="!waiting"
|
||||
full-width
|
||||
>
|
||||
<template #progress-icon />
|
||||
</ProgressBar>
|
||||
<RouterLink to="/downloads" class="self-start text-sm text-brand hover:underline">
|
||||
{{ formatMessage(messages.downloads) }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { useId } from 'vue'
|
||||
|
||||
import type { InstanceMode } from '@/helpers/hosted-packs'
|
||||
|
||||
defineProps<{ modelValue?: InstanceMode; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: InstanceMode] }>()
|
||||
const group = useId()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.instance-mode.title', defaultMessage: 'Instance type' },
|
||||
starlight: { id: 'app.instance-mode.starlight', defaultMessage: 'StarLight instance' },
|
||||
local: { id: 'app.instance-mode.local', defaultMessage: 'Local instance' },
|
||||
starlightDescription: {
|
||||
id: 'app.instance-mode.starlight-description',
|
||||
defaultMessage:
|
||||
'Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting.',
|
||||
},
|
||||
localDescription: {
|
||||
id: 'app.instance-mode.local-description',
|
||||
defaultMessage:
|
||||
'Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<fieldset class="m-0 flex flex-col gap-3 border-0 p-0" :disabled="disabled">
|
||||
<legend class="mb-3 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</legend>
|
||||
<label
|
||||
v-for="mode in ['starlight', 'local'] as const"
|
||||
:key="mode"
|
||||
class="flex items-start gap-3 rounded-xl border border-solid border-divider p-4"
|
||||
:class="[
|
||||
modelValue === mode ? 'bg-surface-3' : 'bg-surface-1',
|
||||
disabled ? 'opacity-60' : 'cursor-pointer',
|
||||
]"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="mt-1"
|
||||
:name="group"
|
||||
:value="mode"
|
||||
:checked="modelValue === mode"
|
||||
@change="emit('update:modelValue', mode)"
|
||||
/>
|
||||
<span class="flex flex-col gap-1"
|
||||
><span class="font-semibold text-contrast">{{ formatMessage(messages[mode]) }}</span
|
||||
><span class="text-sm text-secondary">{{
|
||||
formatMessage(
|
||||
mode === 'starlight' ? messages.starlightDescription : messages.localDescription,
|
||||
)
|
||||
}}</span></span
|
||||
>
|
||||
</label>
|
||||
</fieldset>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
|
||||
import { useInstanceMode, useSetInstanceMode } from '@/composables/useInstanceMode'
|
||||
import type { InstanceMode } from '@/helpers/hosted-packs'
|
||||
|
||||
const props = defineProps<{ instanceId: string; disabled?: boolean }>()
|
||||
const query = useInstanceMode(() => props.instanceId)
|
||||
const save = useSetInstanceMode()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
saving: {
|
||||
id: 'app.instance-mode.saving',
|
||||
defaultMessage: 'Applying instance type and installing the server modpack when needed…',
|
||||
},
|
||||
loading: { id: 'app.instance-mode.loading', defaultMessage: 'Loading instance type…' },
|
||||
retry: { id: 'app.instance-mode.retry', defaultMessage: 'Retry' },
|
||||
})
|
||||
const saving = computed(
|
||||
() => save.isPending.value && save.variables.value?.instanceId === props.instanceId,
|
||||
)
|
||||
const selection = computed(() => (saving.value ? save.variables.value?.mode : query.data.value))
|
||||
const failure = computed(
|
||||
() =>
|
||||
query.error.value ??
|
||||
(save.variables.value?.instanceId === props.instanceId ? save.error.value : null),
|
||||
)
|
||||
function select(mode: InstanceMode) {
|
||||
if (props.disabled || saving.value || !query.data.value || query.data.value === mode) return
|
||||
save.mutate({ instanceId: props.instanceId, mode })
|
||||
}
|
||||
function retry() {
|
||||
if (props.disabled || saving.value) return
|
||||
if (query.error.value) void query.refetch()
|
||||
else if (save.variables.value?.instanceId === props.instanceId) save.mutate(save.variables.value)
|
||||
}
|
||||
watch(
|
||||
() => props.instanceId,
|
||||
() => save.reset(),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<InstanceModeOptions
|
||||
:model-value="selection"
|
||||
:disabled="disabled || !query.data.value || query.isPending.value || saving"
|
||||
@update:model-value="select"
|
||||
/>
|
||||
<p v-if="query.isPending.value || saving" class="m-0 text-secondary" role="status">
|
||||
{{ formatMessage(saving ? messages.saving : messages.loading) }}
|
||||
</p>
|
||||
<div v-if="failure" role="alert" class="flex items-center gap-3">
|
||||
<span>{{ String(failure) }}</span
|
||||
><ButtonStyled
|
||||
><button
|
||||
type="button"
|
||||
:disabled="disabled || query.isFetching.value || saving"
|
||||
@click="retry"
|
||||
>
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||
import SkinSiteLoginModal from '@/components/ui/skin-site/SkinSiteLoginModal.vue'
|
||||
import {
|
||||
requestSkinSitePlayers,
|
||||
skinSitePlayers,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
} from '@/composables/skin-site-session'
|
||||
import { users } from '@/helpers/auth'
|
||||
import { getInstanceMode } from '@/helpers/hosted-packs'
|
||||
import {
|
||||
type InstancePlayer,
|
||||
type PlayerChoice,
|
||||
PlayerSelectionNavigatedAwayError,
|
||||
registerInstancePlayerPicker,
|
||||
saveInstancePlayer,
|
||||
waitForSkinSiteSession,
|
||||
} from '@/helpers/instance-player'
|
||||
|
||||
import { playerMessages as messages } from './instance-player-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
|
||||
const skinSiteLoginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
|
||||
const accounts = ref<PlayerChoice[]>([])
|
||||
const active = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const instanceId = ref('')
|
||||
const locked = ref<InstancePlayer | null>(null)
|
||||
const awaitingSkinLogin = ref(false)
|
||||
let hideForLogin = false
|
||||
let generation = 0
|
||||
let resolveSelection: ((player: InstancePlayer) => void) | undefined
|
||||
let rejectSelection: ((reason: Error) => void) | undefined
|
||||
const choices = computed(() => {
|
||||
const skin: PlayerChoice[] =
|
||||
skinSiteStatus.value === 'signed-in'
|
||||
? skinSitePlayers.value
|
||||
.filter((player) => !player.isMojang)
|
||||
.map((player) => ({
|
||||
id: player.uuid,
|
||||
name: player.name,
|
||||
account_type: 'yggdrasil',
|
||||
skin_site_user: skinSiteUser.value?.uuid,
|
||||
head: player.headDataUrl,
|
||||
}))
|
||||
: []
|
||||
return [...skin, ...accounts.value].filter(
|
||||
(player) =>
|
||||
!locked.value ||
|
||||
(player.id.replaceAll('-', '') === locked.value.id.replaceAll('-', '') &&
|
||||
player.account_type === locked.value.account_type &&
|
||||
(player.skin_site_user ?? null) === (locked.value.skin_site_user ?? null)),
|
||||
)
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
const revision = ++generation
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [available, mode] = await Promise.all([users(), getInstanceMode(instanceId.value)])
|
||||
if (revision !== generation) return
|
||||
accounts.value = available
|
||||
.filter(
|
||||
(account) =>
|
||||
account.account_type === 'microsoft' ||
|
||||
(mode === 'local' && account.account_type === 'offline'),
|
||||
)
|
||||
.map((account) => ({
|
||||
id: account.profile.id,
|
||||
name: account.profile.name,
|
||||
account_type: account.account_type,
|
||||
}))
|
||||
await waitForSkinSiteSession()
|
||||
if (revision !== generation) return
|
||||
if (skinSiteStatus.value === 'signed-in') await requestSkinSitePlayers()
|
||||
if (skinSiteStatus.value === 'error') throw new Error('无法确认皮肤站登录状态,请重试。')
|
||||
} catch (cause) {
|
||||
if (revision === generation)
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
} finally {
|
||||
if (revision === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelled() {
|
||||
if (hideForLogin) {
|
||||
hideForLogin = false
|
||||
return
|
||||
}
|
||||
if (!active.value) return
|
||||
active.value = false
|
||||
awaitingSkinLogin.value = false
|
||||
generation++
|
||||
rejectSelection?.(new Error('已取消选择实例玩家'))
|
||||
resolveSelection = undefined
|
||||
rejectSelection = undefined
|
||||
}
|
||||
|
||||
async function select(player: PlayerChoice) {
|
||||
if (loading.value || saving.value || !active.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { head: _, ...binding } = player
|
||||
await saveInstancePlayer(instanceId.value, binding)
|
||||
active.value = false
|
||||
resolveSelection?.(binding)
|
||||
resolveSelection = undefined
|
||||
rejectSelection = undefined
|
||||
modal.value?.hide()
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function signInSkinSite() {
|
||||
awaitingSkinLogin.value = true
|
||||
hideForLogin = true
|
||||
active.value = false
|
||||
generation++
|
||||
// Settle the pending selection so `prepareInstancePlayer` releases its
|
||||
// in-flight entry; otherwise a later launch would await this forever and
|
||||
// never re-prompt. The caller treats this sentinel as a silent abort.
|
||||
rejectSelection?.(new PlayerSelectionNavigatedAwayError())
|
||||
resolveSelection = undefined
|
||||
rejectSelection = undefined
|
||||
skinSiteLoginModal.value?.show()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const unregister = registerInstancePlayerPicker((id, saved) => {
|
||||
if (active.value) return Promise.reject(new Error('请先完成当前实例的玩家选择。'))
|
||||
instanceId.value = id
|
||||
locked.value = saved
|
||||
accounts.value = []
|
||||
awaitingSkinLogin.value = false
|
||||
active.value = true
|
||||
modal.value?.show()
|
||||
void refresh()
|
||||
return new Promise((resolve, reject) => {
|
||||
resolveSelection = resolve
|
||||
rejectSelection = reject
|
||||
})
|
||||
})
|
||||
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], () => {
|
||||
if (!active.value || saving.value) return
|
||||
if (
|
||||
awaitingSkinLogin.value &&
|
||||
skinSiteStatus.value === 'signed-in' &&
|
||||
(!locked.value?.skin_site_user || locked.value.skin_site_user === skinSiteUser.value?.uuid)
|
||||
) {
|
||||
awaitingSkinLogin.value = false
|
||||
modal.value?.show()
|
||||
}
|
||||
void refresh()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
unregister()
|
||||
cancelled()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
:closable="!saving"
|
||||
:on-hide="cancelled"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="m-0 text-secondary">
|
||||
{{
|
||||
locked
|
||||
? formatMessage(messages.restore, { name: locked.name })
|
||||
: formatMessage(messages.remember)
|
||||
}}
|
||||
</p>
|
||||
<p v-if="loading" class="m-0" role="status">{{ formatMessage(messages.loading) }}</p>
|
||||
<template v-else>
|
||||
<ButtonStyled v-for="player in choices" :key="`${player.account_type}:${player.id}`">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
class="flex items-center gap-3"
|
||||
@click="select(player)"
|
||||
>
|
||||
<img
|
||||
v-if="player.head"
|
||||
:src="player.head"
|
||||
alt=""
|
||||
class="size-8 rounded-md [image-rendering:pixelated]"
|
||||
/>
|
||||
<span>{{ player.name }}</span
|
||||
><span class="text-secondary">{{
|
||||
formatMessage(
|
||||
player.account_type === 'microsoft'
|
||||
? messages.licensed
|
||||
: player.account_type === 'offline'
|
||||
? messages.offline
|
||||
: messages.skin,
|
||||
)
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p v-if="!choices.length && !error && !locked" class="m-0 text-secondary">
|
||||
{{ formatMessage(skinSiteStatus === 'signed-in' ? messages.empty : messages.signIn) }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
|
||||
<ButtonStyled v-if="error"
|
||||
><button :disabled="loading || saving" @click="refresh">
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
skinSiteStatus === 'signed-out' ||
|
||||
(locked?.skin_site_user && locked.skin_site_user !== skinSiteUser?.uuid)
|
||||
"
|
||||
><button :disabled="saving" @click="signInSkinSite">
|
||||
{{ formatMessage(messages.skinLogin) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-secondary">{{ formatMessage(messages.or) }}</span>
|
||||
<ButtonStyled
|
||||
><button :disabled="loading || saving" @click="microsoftLogin?.showDeviceLogin()">
|
||||
{{ formatMessage(messages.useMicrosoft) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
<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>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
chooseInstancePlayer,
|
||||
getInstancePlayer,
|
||||
type InstancePlayer,
|
||||
onInstancePlayerChanged,
|
||||
} from '@/helpers/instance-player'
|
||||
|
||||
import { playerMessages as messages } from './instance-player-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{ instanceId: string }>()
|
||||
const player = ref<InstancePlayer | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
let generation = 0
|
||||
const stop = onInstancePlayerChanged((id, saved) => {
|
||||
if (id === props.instanceId) {
|
||||
generation++
|
||||
player.value = saved
|
||||
busy.value = false
|
||||
error.value = ''
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
generation++
|
||||
stop()
|
||||
})
|
||||
watch(
|
||||
() => props.instanceId,
|
||||
async (id) => {
|
||||
const revision = ++generation
|
||||
player.value = null
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
const saved = await getInstancePlayer(id)
|
||||
if (generation === revision) player.value = saved
|
||||
} catch (cause) {
|
||||
if (generation === revision) error.value = String(cause)
|
||||
} finally {
|
||||
if (generation === revision) busy.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
async function change() {
|
||||
if (busy.value) return
|
||||
const revision = generation
|
||||
const id = props.instanceId
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const saved = await chooseInstancePlayer(id)
|
||||
if (generation === revision) player.value = saved
|
||||
} catch (cause) {
|
||||
if (generation === revision)
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
} finally {
|
||||
if (generation === revision) busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="mb-6 flex flex-col gap-2" data-onboarding-id="instance-player-settings">
|
||||
<h3 class="m-0 text-contrast">{{ formatMessage(messages.setting) }}</h3>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ player ? player.name : formatMessage(messages.firstLaunch) }}
|
||||
</p>
|
||||
<ButtonStyled
|
||||
><button :disabled="busy" @click="change">
|
||||
{{ formatMessage(busy ? messages.loading : messages.change) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div
|
||||
class="management-switch relative isolate mb-4 flex w-full max-w-[26rem] rounded-xl bg-surface-1 p-1"
|
||||
:data-packs="selected === 'packs'"
|
||||
role="group"
|
||||
:aria-label="formatMessage(messages.label)"
|
||||
>
|
||||
<span
|
||||
class="selection absolute inset-y-1 left-1 -z-10 w-[calc(50%_-_4px)] rounded-lg bg-surface-3 shadow-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
|
||||
type="button"
|
||||
:aria-pressed="selected === 'mods'"
|
||||
@click="select('mods')"
|
||||
>
|
||||
{{ formatMessage(messages.mods) }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
|
||||
type="button"
|
||||
:aria-pressed="selected === 'packs'"
|
||||
@click="select('packs')"
|
||||
>
|
||||
{{ formatMessage(messages.packs) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="selected === 'mods'"><slot /></div>
|
||||
<div v-if="openedPacks" v-show="selected === 'packs'"><slot name="packs" /></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
label: { id: 'app.hosted-packs.management', defaultMessage: 'Content management' },
|
||||
mods: { id: 'app.hosted-packs.mods', defaultMessage: 'Manage mods' },
|
||||
packs: { id: 'app.hosted-packs.packs', defaultMessage: 'Manage modpacks' },
|
||||
})
|
||||
const key = 'starlight:instance:mod-management-tab'
|
||||
function initial(): 'mods' | 'packs' {
|
||||
try {
|
||||
return localStorage.getItem(key) === 'packs' ? 'packs' : 'mods'
|
||||
} catch {
|
||||
return 'mods'
|
||||
}
|
||||
}
|
||||
const selected = ref(initial())
|
||||
const openedPacks = ref(selected.value === 'packs')
|
||||
function select(value: 'mods' | 'packs') {
|
||||
selected.value = value
|
||||
if (value === 'packs') openedPacks.value = true
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
/* Selection remains usable without storage. */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.selection {
|
||||
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
[data-packs='true'] .selection {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.selection {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, defineMessages, NewModal, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||
import { nextTick, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { loading_listener } from '@/helpers/events'
|
||||
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import {
|
||||
createTaggedModProgress,
|
||||
type TaggedProgressEvent,
|
||||
type TaggedProgressGroup,
|
||||
} from '@/helpers/tagged-mod-progress'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const state = createTaggedModProgress()
|
||||
const groups = shallowRef<TaggedProgressGroup[]>([])
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.hosted-mods.title', defaultMessage: 'Updating server Mods' },
|
||||
description: {
|
||||
id: 'app.hosted-mods.description',
|
||||
defaultMessage: 'The game starts after all required updates have been installed.',
|
||||
},
|
||||
complete: { id: 'app.hosted-mods.complete', defaultMessage: 'Downloaded' },
|
||||
downloads: { id: 'app.hosted-mods.downloads', defaultMessage: 'View downloads' },
|
||||
close: { id: 'app.hosted-mods.close', defaultMessage: 'Close' },
|
||||
})
|
||||
const size = (value: number) => `${(value / 1048576).toFixed(1)} MiB`
|
||||
let disposed = false
|
||||
let unlisten: (() => void) | undefined
|
||||
let stopAttempts: (() => void) | undefined
|
||||
function refresh() {
|
||||
groups.value = [...state.groups.values()]
|
||||
}
|
||||
function consume(payload: TaggedProgressEvent) {
|
||||
const opened = state.update(payload)
|
||||
refresh()
|
||||
if (opened)
|
||||
void nextTick(() => {
|
||||
const group = state.groups.get(payload.event?.batch_id ?? '')
|
||||
if (!disposed && group && (!group.done || group.error)) modal.value?.show()
|
||||
})
|
||||
if (groups.value.length > 0 && groups.value.every((group) => group.done && !group.error))
|
||||
modal.value?.hide()
|
||||
}
|
||||
onMounted(async () => {
|
||||
stopAttempts = onHostedPackAttemptStarted((id) => {
|
||||
state.reset(id)
|
||||
refresh()
|
||||
if (!groups.value.length) modal.value?.hide()
|
||||
})
|
||||
let initializing = true
|
||||
const buffered: TaggedProgressEvent[] = []
|
||||
const stop = await loading_listener((payload: TaggedProgressEvent) => {
|
||||
if (initializing) buffered.push(payload)
|
||||
else consume(payload)
|
||||
})
|
||||
if (disposed) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
unlisten = stop
|
||||
const bars = await progress_bars_list().catch(() => ({}))
|
||||
if (disposed) return
|
||||
const ordered = Object.values(bars).sort(
|
||||
(a, b) =>
|
||||
Number(b.bar_type?.type === 'hosted_pack_sync') -
|
||||
Number(a.bar_type?.type === 'hosted_pack_sync'),
|
||||
)
|
||||
for (const bar of ordered)
|
||||
consume({
|
||||
loader_uuid: String(bar.loading_bar_uuid),
|
||||
event: bar.bar_type,
|
||||
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
|
||||
total: bar.total,
|
||||
message: bar.message ?? '',
|
||||
})
|
||||
initializing = false
|
||||
for (const payload of buffered) consume(payload)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
unlisten?.()
|
||||
stopAttempts?.()
|
||||
})
|
||||
function openDownloads() {
|
||||
modal.value?.hide()
|
||||
void router.push('/downloads')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(38rem, calc(100vw - 2rem))"
|
||||
scrollable
|
||||
max-content-height="60vh"
|
||||
:close-on-click-outside="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
<section v-for="group in groups" :key="group.id" class="flex min-w-0 flex-col gap-3">
|
||||
<h3 class="m-0 text-contrast">{{ group.name }}</h3>
|
||||
<p v-if="group.error" role="alert" class="m-0 break-words text-red">{{ group.error }}</p>
|
||||
<p v-else-if="group.message" class="m-0 break-words text-sm text-secondary">
|
||||
{{ group.message }}
|
||||
</p>
|
||||
<div
|
||||
v-for="file in group.files.values()"
|
||||
:key="file.id"
|
||||
class="flex min-w-0 flex-col gap-1"
|
||||
>
|
||||
<div class="flex flex-wrap justify-between gap-2 text-sm">
|
||||
<span class="min-w-0 break-all text-contrast">{{ file.name }}</span>
|
||||
<span class="text-secondary tabular-nums"
|
||||
>{{ size(file.current) }} / {{ size(file.total) }}</span
|
||||
>
|
||||
</div>
|
||||
<p v-if="file.error" class="m-0 break-words text-sm text-red">{{ file.error }}</p>
|
||||
<ProgressBar
|
||||
v-else
|
||||
:progress="file.current"
|
||||
:max="file.total || 1"
|
||||
:waiting="!file.total"
|
||||
:show-progress="!file.done"
|
||||
:label="file.done ? formatMessage(messages.complete) : file.message"
|
||||
full-width
|
||||
><template #progress-icon
|
||||
/></ProgressBar>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="openDownloads">
|
||||
{{ formatMessage(messages.downloads) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide()">
|
||||
{{ formatMessage(messages.close) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineMessages } from '@modrinth/ui'
|
||||
|
||||
export const playerMessages = defineMessages({
|
||||
title: { id: 'app.instance-player.title', defaultMessage: 'Choose an instance player' },
|
||||
remember: {
|
||||
id: 'app.instance-player.remember',
|
||||
defaultMessage:
|
||||
'This instance will keep using your choice. Switch players in instance settings.',
|
||||
},
|
||||
restore: {
|
||||
id: 'app.instance-player.restore',
|
||||
defaultMessage: 'Sign in again as {name}. To choose someone else, open instance settings.',
|
||||
},
|
||||
loading: { id: 'app.instance-player.loading', defaultMessage: 'Loading signed-in players…' },
|
||||
licensed: { id: 'app.instance-player.licensed', defaultMessage: 'Minecraft account' },
|
||||
skin: { id: 'app.instance-player.skin', defaultMessage: 'Skin site player' },
|
||||
offline: { id: 'app.instance-player.offline', defaultMessage: 'Offline player' },
|
||||
empty: {
|
||||
id: 'app.instance-player.empty',
|
||||
defaultMessage: 'No available skin site players. You can create a player on the skin site.',
|
||||
},
|
||||
signIn: { id: 'app.instance-player.sign-in', defaultMessage: 'Sign in to choose a player.' },
|
||||
retry: { id: 'app.instance-player.retry', defaultMessage: 'Retry' },
|
||||
skinLogin: { id: 'app.instance-player.skin-login', defaultMessage: 'Sign in to skin site' },
|
||||
or: { id: 'app.instance-player.or', defaultMessage: 'Or' },
|
||||
useMicrosoft: {
|
||||
id: 'app.instance-player.use-microsoft',
|
||||
defaultMessage: 'Use a Minecraft account',
|
||||
},
|
||||
saving: { id: 'app.instance-player.saving', defaultMessage: 'Saving instance player…' },
|
||||
setting: { id: 'app.instance-player.setting', defaultMessage: 'Instance player' },
|
||||
firstLaunch: {
|
||||
id: 'app.instance-player.first-launch',
|
||||
defaultMessage: 'Choose a player on first launch',
|
||||
},
|
||||
change: { id: 'app.instance-player.change', defaultMessage: 'Switch instance player' },
|
||||
})
|
||||
@@ -12,10 +12,14 @@
|
||||
>
|
||||
{{ formatMessage(messages.score, { score }) }}
|
||||
</span>
|
||||
<span class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary">
|
||||
<span
|
||||
class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.highestLevel, { level: highestLevel + 1 }) }}
|
||||
</span>
|
||||
<span class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary">
|
||||
<span
|
||||
class="shrink-0 whitespace-nowrap rounded-full bg-surface-3 px-2 py-0.5 tabular-nums text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.best, { score: bestScore }) }}
|
||||
</span>
|
||||
<NewButton type="base" size="sm" class="shrink-0" @click="settleGame">
|
||||
@@ -80,7 +84,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div v-if="gameOver && !endedManually" class="tide-drain pointer-events-none absolute inset-0 z-20" />
|
||||
<div
|
||||
v-if="gameOver && !endedManually"
|
||||
class="tide-drain pointer-events-none absolute inset-0 z-20"
|
||||
/>
|
||||
<Transition name="panel">
|
||||
<div
|
||||
v-if="gameOver"
|
||||
@@ -115,16 +122,26 @@
|
||||
</div>
|
||||
<div class="grid w-full min-w-0 grid-cols-3 gap-1.5">
|
||||
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
|
||||
<span class="text-[9px] text-secondary">{{ formatMessage(messages.levelLabel) }}</span>
|
||||
<span class="text-sm font-bold tabular-nums text-contrast">{{ highestLevel + 1 }}</span>
|
||||
<span class="text-[9px] text-secondary">{{
|
||||
formatMessage(messages.levelLabel)
|
||||
}}</span>
|
||||
<span class="text-sm font-bold tabular-nums text-contrast">{{
|
||||
highestLevel + 1
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
|
||||
<span class="text-[9px] text-secondary">{{ formatMessage(messages.bestLabel) }}</span>
|
||||
<span class="text-[9px] text-secondary">{{
|
||||
formatMessage(messages.bestLabel)
|
||||
}}</span>
|
||||
<span class="text-sm font-bold tabular-nums text-contrast">{{ bestScore }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col items-center rounded-lg bg-surface-3 px-1 py-1">
|
||||
<span class="text-[9px] text-secondary">{{ formatMessage(messages.overtimesLabel) }}</span>
|
||||
<span class="text-sm font-bold tabular-nums text-contrast">{{ overtimeCount }}</span>
|
||||
<span class="text-[9px] text-secondary">{{
|
||||
formatMessage(messages.overtimesLabel)
|
||||
}}</span>
|
||||
<span class="text-sm font-bold tabular-nums text-contrast">{{
|
||||
overtimeCount
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
@@ -150,8 +167,8 @@ import blueStar from '@/assets/stars/blue.png'
|
||||
import greenStar from '@/assets/stars/green.png'
|
||||
import pinkStar from '@/assets/stars/pink.png'
|
||||
import redStar from '@/assets/stars/red.png'
|
||||
import yellowStar from '@/assets/stars/yellow.png'
|
||||
import superStar from '@/assets/stars/super.png'
|
||||
import yellowStar from '@/assets/stars/yellow.png'
|
||||
|
||||
interface Piece {
|
||||
id: number
|
||||
@@ -396,7 +413,14 @@ function mergePieces(a: Piece, b: Piece) {
|
||||
score.value += points[level]
|
||||
highestLevel.value = Math.max(highestLevel.value, level)
|
||||
const isRainbow = level === colors.length - 1
|
||||
spawnBurst(x, y, isRainbow ? rainbowColors : [colors[level]], 10 + level * 3, 130 + level * 20, 3.4)
|
||||
spawnBurst(
|
||||
x,
|
||||
y,
|
||||
isRainbow ? rainbowColors : [colors[level]],
|
||||
10 + level * 3,
|
||||
130 + level * 20,
|
||||
3.4,
|
||||
)
|
||||
spawnShockwave(x, y, 46 + level * 26, 'rgba(255, 255, 255, 0.9)', Math.min(3, 1.4 + level * 0.3))
|
||||
shakePower = Math.max(shakePower, Math.min(2 + level * 1.2, 7))
|
||||
spawnFloater(x, y, `+${points[level]}`, 15 + level * 3, colors[level])
|
||||
@@ -423,7 +447,14 @@ function easeOutBack(t: number) {
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
|
||||
}
|
||||
|
||||
function spawnBurst(x: number, y: number, palette: string[], count: number, speed: number, size: number) {
|
||||
function spawnBurst(
|
||||
x: number,
|
||||
y: number,
|
||||
palette: string[],
|
||||
count: number,
|
||||
speed: number,
|
||||
size: number,
|
||||
) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (particles.length >= 260) return
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
@@ -508,7 +539,8 @@ function update(delta: number) {
|
||||
if (overtimeStartAt >= 0) {
|
||||
const overtimeElapsed = elapsed - overtimeStartAt
|
||||
if (overtimeElapsed < 0.8) {
|
||||
surfaceRatio = overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8)
|
||||
surfaceRatio =
|
||||
overtimeFromRatio + (0.05 - overtimeFromRatio) * easeInOutCubic(overtimeElapsed / 0.8)
|
||||
} else {
|
||||
overtimeStartAt = -1
|
||||
}
|
||||
@@ -670,17 +702,7 @@ function prepareBallSprite(src: string, level: number) {
|
||||
sprite.height = Math.max(1, Math.round(bounds.h * scale))
|
||||
const ctx = sprite.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.drawImage(
|
||||
image,
|
||||
bounds.x,
|
||||
bounds.y,
|
||||
bounds.w,
|
||||
bounds.h,
|
||||
0,
|
||||
0,
|
||||
sprite.width,
|
||||
sprite.height,
|
||||
)
|
||||
ctx.drawImage(image, bounds.x, bounds.y, bounds.w, bounds.h, 0, 0, sprite.width, sprite.height)
|
||||
ballSprites[level] = sprite
|
||||
}
|
||||
image.src = src
|
||||
@@ -692,9 +714,7 @@ function drawMergePiece(piece: Piece) {
|
||||
const spawnProgress = Math.min(1, (elapsed - piece.spawnAt) / 0.22)
|
||||
const spawnScale = spawnProgress >= 1 ? 1 : easeOutBack(spawnProgress)
|
||||
const radius =
|
||||
piece.radius *
|
||||
spawnScale *
|
||||
(isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
|
||||
piece.radius * spawnScale * (isRainbow ? 1 + 0.05 * Math.sin(elapsed * 3.2 + piece.id) : 1)
|
||||
context.save()
|
||||
context.translate(piece.x, piece.y)
|
||||
const hue = (elapsed * 55) % 360
|
||||
@@ -792,9 +812,7 @@ function draw() {
|
||||
context.beginPath()
|
||||
for (let x = 0; x <= width; x += 8) {
|
||||
const waveY =
|
||||
surfaceY +
|
||||
Math.sin(x * 0.045 + elapsed * 1.8) * 2.5 +
|
||||
Math.sin(x * 0.013 + elapsed * 0.9) * 2
|
||||
surfaceY + Math.sin(x * 0.045 + elapsed * 1.8) * 2.5 + Math.sin(x * 0.013 + elapsed * 0.9) * 2
|
||||
if (x === 0) context.moveTo(x, waveY)
|
||||
else context.lineTo(x, waveY)
|
||||
}
|
||||
@@ -807,7 +825,12 @@ function draw() {
|
||||
const progress = Math.min(1, (elapsed - waveStart) / 0.8)
|
||||
const waveY = height * (1 - progress)
|
||||
const bandHeight = Math.max(22, height * 0.1)
|
||||
const gradient = context.createLinearGradient(0, waveY - bandHeight, 0, waveY + bandHeight * 0.5)
|
||||
const gradient = context.createLinearGradient(
|
||||
0,
|
||||
waveY - bandHeight,
|
||||
0,
|
||||
waveY + bandHeight * 0.5,
|
||||
)
|
||||
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)')
|
||||
gradient.addColorStop(0.55, 'rgba(96, 178, 255, 0.5)')
|
||||
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
|
||||
|
||||
@@ -1,318 +1,44 @@
|
||||
<template>
|
||||
<canvas id="about_scene" class="size-full" />
|
||||
<canvas ref="canvas" class="about-scene" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as THREE from 'three'
|
||||
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
|
||||
import { onActivated, onDeactivated, onMounted, onScopeDispose, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { useTheming } from '@/store/theme'
|
||||
import { createWitherVictoryScene } from './about-scene/wither-victory'
|
||||
|
||||
const themeStore = useTheming()
|
||||
function isDarkMode() {
|
||||
if (themeStore.selectedTheme == 'system') {
|
||||
return matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return ['dark', 'oled'].includes(themeStore.selectedTheme)
|
||||
const props = defineProps<{ paused?: boolean }>()
|
||||
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
|
||||
let scene: ReturnType<typeof createWitherVictoryScene> | undefined
|
||||
let active = true
|
||||
|
||||
function updatePlayback() {
|
||||
scene?.setPaused(!active || Boolean(props.paused))
|
||||
}
|
||||
|
||||
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) {
|
||||
const tipGeometry = new THREE.SphereGeometry(2)
|
||||
const tipMaterial = new THREE.MeshBasicMaterial({ color })
|
||||
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial)
|
||||
tipMesh.position.copy(position)
|
||||
return tipMesh
|
||||
}
|
||||
|
||||
function createWaterMaterial(): THREE.ShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
time: { value: 0 },
|
||||
seed: { value: Math.random() * 83 + 17 },
|
||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
||||
},
|
||||
transparent: true,
|
||||
vertexShader: `#define WATER_VERT
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `#define WATER_FRAG
|
||||
uniform float time;
|
||||
uniform float seed;
|
||||
uniform vec3 color;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec2 randomGradient(vec2 p) {
|
||||
float n = sin(dot(p, vec2(127.1, 311.7)));
|
||||
float angle = fract(n * 43758.5453123) * 6.28318530718 * seed;
|
||||
return vec2(cos(angle), sin(angle));
|
||||
}
|
||||
|
||||
float perlinNoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
vec2 g1 = randomGradient(i);
|
||||
vec2 g2 = randomGradient(i + vec2(1.0, 0.0));
|
||||
vec2 g3 = randomGradient(i + vec2(0.0, 1.0));
|
||||
vec2 g4 = randomGradient(i + vec2(1.0, 1.0));
|
||||
|
||||
vec2 d1 = f;
|
||||
vec2 d2 = f - vec2(1.0, 0.0);
|
||||
vec2 d3 = f - vec2(0.0, 1.0);
|
||||
vec2 d4 = f - vec2(1.0, 1.0);
|
||||
|
||||
float v1 = dot(g1, d1);
|
||||
float v2 = dot(g2, d2);
|
||||
float v3 = dot(g3, d3);
|
||||
float v4 = dot(g4, d4);
|
||||
|
||||
return mix(mix(v1, v2, u.x), mix(v3, v4, u.x), u.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float height = 0.0;
|
||||
height += perlinNoise(vec2(vUv.x * 10.0, time * 0.8)) * 0.3;
|
||||
height += perlinNoise(vec2(vUv.x * 5.0, time * 0.4)) * 0.35;
|
||||
height += perlinNoise(vec2(vUv.x * 2.5, time * 0.2)) * 0.15;
|
||||
height += perlinNoise(vec2(vUv.x * 2.0, time * 0.2)) * 0.2;
|
||||
height = clamp(height, -1.0, 1.0);
|
||||
height = height * 0.8 + 0.6;
|
||||
|
||||
float thickness = 0.008;
|
||||
if(vUv.y < height - thickness) {
|
||||
float scalar = 1.0 - height + vUv.y;
|
||||
scalar = scalar * scalar * scalar * 0.6;
|
||||
gl_FragColor = vec4(color, scalar);
|
||||
} else if(vUv.y > height + thickness) {
|
||||
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
} else {
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
||||
function createWater(material: THREE.ShaderMaterial, position: THREE.Vector3) {
|
||||
const geometry = new THREE.PlaneGeometry(120, 16)
|
||||
const waterMesh = new THREE.Mesh(geometry, material)
|
||||
waterMesh.position.copy(position)
|
||||
return waterMesh
|
||||
}
|
||||
|
||||
function createCircleMaterial(): THREE.ShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
||||
},
|
||||
transparent: true,
|
||||
vertexShader: `#define CIRCLE_VERT
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `#define CIRCLE_FRAG
|
||||
varying vec2 vUv;
|
||||
uniform vec3 color;
|
||||
float remap(float v, float inMin, float inMax, float outMin, float outMax) {
|
||||
float t = (v - inMin) / (inMax - inMin);
|
||||
return outMin + (outMax - outMin) * t;
|
||||
}
|
||||
void main() {
|
||||
float dis = distance(vUv, vec2(0.5));
|
||||
float thickness = 0.05;
|
||||
|
||||
gl_FragColor = vec4(0.0);
|
||||
if(dis <= 0.35 && dis >= 0.35 - thickness) {
|
||||
gl_FragColor = vec4(color, 0.8);
|
||||
} else {
|
||||
// emissive
|
||||
float scalar = 0.0;
|
||||
if(dis >= 0.35) {
|
||||
scalar = clamp(0.5 - dis, 0.0, 0.15);
|
||||
scalar = remap(scalar, 0.0, 0.15, 0.0, 1.0);
|
||||
} else {
|
||||
scalar = clamp(0.35 - dis, 0.0, 0.5);
|
||||
scalar = remap(scalar, 0.0, 0.35, 1.0, 0.0);
|
||||
}
|
||||
scalar = clamp(scalar * scalar * scalar, 0.0, 1.0);
|
||||
gl_FragColor = vec4(color, scalar);
|
||||
}
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
||||
function createCircle(material: THREE.ShaderMaterial, position: THREE.Vector3) {
|
||||
const geometry = new THREE.PlaneGeometry(0.6, 0.6)
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
mesh.position.copy(position)
|
||||
return mesh
|
||||
}
|
||||
|
||||
function main() {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('#about_scene')
|
||||
if (!canvas) return console.error('No canvas')
|
||||
|
||||
let isUpdating = true
|
||||
|
||||
const canvasSize = new THREE.Vector2(
|
||||
canvas.getBoundingClientRect().width,
|
||||
canvas.getBoundingClientRect().height,
|
||||
)
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
canvas,
|
||||
})
|
||||
renderer.setPixelRatio(devicePixelRatio)
|
||||
renderer.setSize(canvasSize.x, canvasSize.y)
|
||||
|
||||
const deltaClock = new THREE.Clock()
|
||||
const elapseClock = new THREE.Clock()
|
||||
deltaClock.start()
|
||||
elapseClock.start()
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(30, canvasSize.x / canvasSize.y, 1, 3000)
|
||||
camera.fov *= 0.7
|
||||
camera.position.set(-10, 5, 30)
|
||||
camera.lookAt(0, 0, 0)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff)
|
||||
scene.add(ambientLight)
|
||||
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 4.0)
|
||||
dirLight.position.set(-30, 30, 28)
|
||||
scene.add(dirLight)
|
||||
|
||||
scene.add(createTip(dirLight.position, 0xffff00))
|
||||
scene.add(createTip(camera.position))
|
||||
|
||||
const accentColor =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-brand').trim() || '#4444ff'
|
||||
|
||||
const waterMaterial = createWaterMaterial()
|
||||
waterMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
|
||||
isDarkMode() ? 0.6 : 2.4,
|
||||
)
|
||||
// .multiplyScalar(0.6)
|
||||
// .multiplyScalar(2.4)
|
||||
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(0, -6.5, 4)))
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(2, -8, -10)))
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(16, -8, -26)))
|
||||
|
||||
// 静止的下界之星(替换原动态美西螈)
|
||||
const starTexture = new THREE.TextureLoader().load('/models/netherstar.png')
|
||||
starTexture.colorSpace = THREE.SRGBColorSpace
|
||||
const starMaterial = new THREE.SpriteMaterial({ map: starTexture, transparent: true })
|
||||
const starSprite = new THREE.Sprite(starMaterial)
|
||||
starSprite.scale.set(8, 8, 1)
|
||||
starSprite.position.set(0, -2.5, 0)
|
||||
scene.add(starSprite)
|
||||
|
||||
let updateGLTF = (_deltaTime: number, _elapsedTime: number) => {}
|
||||
|
||||
const circleMaterial = createCircleMaterial()
|
||||
circleMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
|
||||
isDarkMode() ? 1.2 : 3,
|
||||
)
|
||||
// .multiplyScalar(1.2)
|
||||
// .multiplyScalar(3)
|
||||
|
||||
let circleMeshList: THREE.Mesh[] = []
|
||||
let nextCircleCreateTime = 0.0
|
||||
function updateCircle(deltaTime: number, elapsedTime: number) {
|
||||
circleMeshList = circleMeshList.filter((m) => {
|
||||
m.position.y += deltaTime * 2.0
|
||||
if (m.position.y >= 32) {
|
||||
scene.remove(m)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (elapsedTime >= nextCircleCreateTime) {
|
||||
nextCircleCreateTime = elapsedTime + Math.random() * 0.8
|
||||
const circle = createCircle(
|
||||
circleMaterial,
|
||||
new THREE.Vector3(Math.random() * 64 - 32 - 12, -20, Math.random() * 6 + 1),
|
||||
)
|
||||
scene.add(circle)
|
||||
circleMeshList.push(circle)
|
||||
}
|
||||
}
|
||||
|
||||
function animate(_time: number) {
|
||||
if (isUpdating === false) return
|
||||
requestAnimationFrame(animate)
|
||||
|
||||
const deltaTime = deltaClock.getDelta()
|
||||
const elapsedTime = elapseClock.getElapsedTime()
|
||||
|
||||
updateGLTF(deltaTime, elapsedTime)
|
||||
waterMaterial.uniforms.time.value = elapsedTime
|
||||
|
||||
updateCircle(deltaTime, elapsedTime)
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
animate(Date.now())
|
||||
|
||||
const originCameraPosition = camera.position.clone()
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
const mouseXOffsetRatio = ((event.clientX - innerWidth / 2) / innerWidth) * 2
|
||||
const mouseYOffsetRatio = ((event.clientY - innerHeight / 2) / innerHeight) * 2
|
||||
const newPosition = new THREE.Vector3(
|
||||
originCameraPosition.x + mouseXOffsetRatio,
|
||||
originCameraPosition.y + mouseYOffsetRatio * 0.5,
|
||||
originCameraPosition.z,
|
||||
)
|
||||
camera.position.copy(newPosition)
|
||||
}
|
||||
|
||||
function updateSize() {
|
||||
if (!isUpdating) return
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
if (w > 0 && h > 0) {
|
||||
renderer.setSize(w, h)
|
||||
camera.aspect = w / h
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize)
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
addEventListener('mousemove', onMouseMove)
|
||||
onScopeDispose(() => {
|
||||
isUpdating = false
|
||||
removeEventListener('mousemove', onMouseMove)
|
||||
resizeObserver.disconnect()
|
||||
deltaClock.stop()
|
||||
elapseClock.stop()
|
||||
renderer.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(main)
|
||||
onMounted(() => {
|
||||
if (!canvas.value) return
|
||||
scene = createWitherVictoryScene(canvas.value)
|
||||
updatePlayback()
|
||||
scene.ready.catch((error: unknown) => console.warn('Unable to load about-page scene', error))
|
||||
})
|
||||
watch(() => props.paused, updatePlayback)
|
||||
onActivated(() => {
|
||||
active = true
|
||||
updatePlayback()
|
||||
})
|
||||
onDeactivated(() => {
|
||||
active = false
|
||||
updatePlayback()
|
||||
})
|
||||
onScopeDispose(() => scene?.dispose())
|
||||
</script>
|
||||
<style>
|
||||
#about_scene {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%),
|
||||
#00000000 40%
|
||||
);
|
||||
|
||||
<style scoped>
|
||||
.about-scene {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0c1729;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
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, h, nextTick, Suspense } from 'vue'
|
||||
|
||||
import { useNetworkStatus } from '../../composables/useNetworkStatus.ts'
|
||||
|
||||
// Exercise the real component's setup and reactive account list. Only platform,
|
||||
// navigation, and visual dependencies are replaced; auth/session logic stays real.
|
||||
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()=>{}})`,
|
||||
'@tauri-apps/api/event': `export const listen=async()=>()=>{}`,
|
||||
'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()=>[]`,
|
||||
'@/store/error.js': `export const handleSevereError=()=>{}`,
|
||||
'@/store/state': `export const useTheming=()=>({homeLayout:'standard'})`,
|
||||
}
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier in stubs) {
|
||||
return {
|
||||
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
|
||||
shortCircuit: true,
|
||||
}
|
||||
}
|
||||
if (specifier.startsWith('@/')) {
|
||||
const file = new URL(`../../${specifier.slice(2)}`, import.meta.url)
|
||||
if (!/\.(ts|js)$/.test(file.pathname))
|
||||
file.pathname += specifier === '@/helpers/auth' ? '.js' : '.ts'
|
||||
return nextResolve(file.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: 'accounts-card-test' }).content),
|
||||
shortCircuit: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
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>
|
||||
type TestAccount = {
|
||||
account_type: 'microsoft' | 'yggdrasil'
|
||||
profile: { id: string; name: string }
|
||||
yggdrasil?: { api_root: string; server_name: string; login: string }
|
||||
}
|
||||
type CardState = {
|
||||
accounts: TestAccount[]
|
||||
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>({
|
||||
createElement: () => ({}),
|
||||
createText: () => ({}),
|
||||
createComment: () => ({}),
|
||||
insert() {},
|
||||
remove() {},
|
||||
setText() {},
|
||||
setElementText() {},
|
||||
patchProp() {},
|
||||
parentNode: () => null,
|
||||
nextSibling: () => null,
|
||||
})
|
||||
const flush = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
await nextTick()
|
||||
}
|
||||
const firstId = '12345678-1234-1234-1234-123456789abc'
|
||||
const secondId = '22345678-1234-1234-1234-123456789abc'
|
||||
|
||||
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: officialId, name: 'Official' } }]
|
||||
: []
|
||||
let selected: string | undefined = hasOfficial ? officialId : undefined
|
||||
let rejectLogin = false
|
||||
let loginCalls = 0
|
||||
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),
|
||||
},
|
||||
})
|
||||
;(
|
||||
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()
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
__TAURI_INTERNALS__: {
|
||||
async invoke(command: string, args: Record<string, unknown>) {
|
||||
if (command === 'plugin:auth|get_default_user') return selected
|
||||
if (command === 'plugin:auth|get_users') return stored
|
||||
if (command === 'plugin:auth|login_skin_site_player') {
|
||||
loginCalls++
|
||||
assert.equal(args.userId, 'owner')
|
||||
assert.equal(args.token, 'fixture.jwt')
|
||||
assert.ok(typeof args.playerId === 'string')
|
||||
if (rejectLogin) throw new Error('Login failed')
|
||||
stored.push({
|
||||
profile: { id: args.playerId, name: args.playerId === firstId ? 'First' : 'Second' },
|
||||
account_type: 'yggdrasil',
|
||||
yggdrasil: {
|
||||
api_root: `${SKIN_SITE_ORIGIN}/yggdrasil`,
|
||||
server_name: 'StarLight',
|
||||
login: 'owner',
|
||||
},
|
||||
})
|
||||
return stored[stored.length - 1]
|
||||
}
|
||||
if (command === 'plugin:auth|set_default_user') {
|
||||
assert.ok(typeof args.user === 'string')
|
||||
assert.ok(stored.some((account) => account.profile.id === args.user))
|
||||
selected = args.user
|
||||
return
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
resetSkinSiteSession()
|
||||
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,
|
||||
async login() {
|
||||
await signInSkinSite({ username: 'Owner', password: 'password', clientId: 'test' })
|
||||
await flush()
|
||||
},
|
||||
signOut() {
|
||||
signOutSkinSite()
|
||||
},
|
||||
failLogin(value: boolean) {
|
||||
rejectLogin = value
|
||||
},
|
||||
get selected() {
|
||||
return selected
|
||||
},
|
||||
get loginCalls() {
|
||||
return loginCalls
|
||||
},
|
||||
close() {
|
||||
app.unmount()
|
||||
resetSkinSiteSession()
|
||||
Reflect.deleteProperty(globalThis, 'localStorage')
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
|
||||
else Reflect.deleteProperty(globalThis, 'window')
|
||||
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
|
||||
else Reflect.deleteProperty(globalThis, 'navigator')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('sidebar offers every skin-site player before any game authentication or launch', async () => {
|
||||
const view = await fixture(false)
|
||||
try {
|
||||
view.failLogin(true)
|
||||
await view.login()
|
||||
await flush()
|
||||
assert.deepEqual(
|
||||
view.state.accounts.map((account) => account.profile.id),
|
||||
[firstId, secondId],
|
||||
)
|
||||
assert.equal(view.loginCalls, 0)
|
||||
assert.equal(view.selected, undefined)
|
||||
} finally {
|
||||
view.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => {
|
||||
const view = await fixture()
|
||||
try {
|
||||
await view.login()
|
||||
await flush()
|
||||
await view.state.setAccount(
|
||||
view.state.accounts.find((account) => account.profile.id === secondId)!,
|
||||
)
|
||||
assert.equal(view.selected, secondId)
|
||||
assert.equal(view.state.selectedAccount?.profile.id, secondId)
|
||||
assert.equal(view.state.accounts.length, 3)
|
||||
assert.equal(view.loginCalls, 1)
|
||||
} finally {
|
||||
view.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('failed player authentication preserves the default and allows retry', async () => {
|
||||
const view = await fixture()
|
||||
try {
|
||||
await view.login()
|
||||
await flush()
|
||||
view.failLogin(true)
|
||||
await view.state.setAccount(
|
||||
view.state.accounts.find((account) => account.profile.id === secondId)!,
|
||||
)
|
||||
assert.equal(view.selected, 'official')
|
||||
assert.equal(view.state.defaultUser, 'official')
|
||||
view.failLogin(false)
|
||||
await view.state.setAccount(
|
||||
view.state.accounts.find((account) => account.profile.id === secondId)!,
|
||||
)
|
||||
assert.equal(view.selected, secondId)
|
||||
} finally {
|
||||
view.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('signing out removes unregistered choices while keeping saved accounts', async () => {
|
||||
const view = await fixture()
|
||||
try {
|
||||
await view.login()
|
||||
await flush()
|
||||
await view.state.setAccount(
|
||||
view.state.accounts.find((account) => account.profile.id === firstId)!,
|
||||
)
|
||||
view.signOut()
|
||||
await flush()
|
||||
assert.deepEqual(
|
||||
view.state.accounts.map((account) => account.profile.id),
|
||||
[firstId, 'official'],
|
||||
)
|
||||
assert.equal(view.selected, firstId)
|
||||
await view.login()
|
||||
await flush()
|
||||
assert.equal(view.state.accounts.length, 3)
|
||||
assert.equal(view.loginCalls, 1)
|
||||
} finally {
|
||||
view.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('a new player without a skin can render the shared empty avatar', async () => {
|
||||
const view = await fixture(false)
|
||||
try {
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,106 @@
|
||||
<template>
|
||||
<div v-if="skinSiteUser" class="flex items-center gap-3 mt-2 p-3 rounded-xl bg-button-bg">
|
||||
<Avatar :src="axolotlLogo" size="36px" />
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-semibold text-contrast">{{ skinSiteUser.username }}</span>
|
||||
<span class="text-secondary text-xs">{{ formatMessage(messages.skinSiteSignedIn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="skinSiteStatus === 'checking'" class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.skinSiteChecking) }}
|
||||
</p>
|
||||
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.skinSiteSyncError) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="skinSiteUser"
|
||||
class="mt-2 overflow-hidden rounded-xl border border-solid border-surface-5 bg-button-bg"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.skinSitePlayers, { count: skinSitePlayers.length }) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="skinSitePlayersStatus === 'error'"
|
||||
type="button"
|
||||
class="button-base flex cursor-pointer items-center gap-1 border-0 bg-transparent p-1 text-xs text-secondary hover:text-brand"
|
||||
@click="refreshSkinSitePlayers()"
|
||||
>
|
||||
<RefreshCwIcon class="h-4 w-4" />
|
||||
{{ formatMessage(messages.retrySkinSitePlayers) }}
|
||||
</button>
|
||||
<SpinnerIcon
|
||||
v-else-if="skinSitePlayersStatus === 'checking'"
|
||||
class="h-4 w-4 animate-spin text-secondary"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
|
||||
<button
|
||||
v-for="player in skinSitePlayers"
|
||||
:key="player.uuid"
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-button-hover"
|
||||
:class="selectedSkinSitePlayerId === player.uuid ? 'bg-button-hover' : 'bg-transparent'"
|
||||
:aria-pressed="selectedSkinSitePlayerId === player.uuid"
|
||||
@click="setSkinSitePlayer(player.uuid)"
|
||||
>
|
||||
<Avatar
|
||||
v-if="player.skinState === 'ready' && player.headDataUrl"
|
||||
:src="player.headDataUrl"
|
||||
size="32px"
|
||||
pixelated
|
||||
:unframed-natural-width="36"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="h-8 w-8 shrink-0 rounded-sm border-2 border-dashed border-secondary/45 bg-surface-5/50 text-secondary"
|
||||
style="
|
||||
background-image:
|
||||
linear-gradient(currentColor, currentColor),
|
||||
linear-gradient(currentColor, currentColor),
|
||||
linear-gradient(currentColor, currentColor);
|
||||
background-position:
|
||||
6px 8px,
|
||||
22px 8px,
|
||||
8px 20px;
|
||||
background-repeat: no-repeat;
|
||||
background-size:
|
||||
4px 4px,
|
||||
4px 4px,
|
||||
16px 2px;
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-primary">{{ player.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
|
||||
{{ formatMessage(messages.noSkinSitePlayers) }}
|
||||
</p>
|
||||
<p v-else-if="skinSitePlayersStatus === 'error'" class="m-0 px-3 pb-3 text-sm text-secondary">
|
||||
{{ formatMessage(messages.skinSitePlayersError) }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
|
||||
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||
<LogInIcon />
|
||||
{{ formatMessage(messages.signInToStarlight) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
v-if="offline"
|
||||
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
|
||||
>
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.offlineMode) }}</span>
|
||||
<span class="text-sm text-secondary">{{ formatMessage(messages.offlineModeDescription) }}</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{
|
||||
formatMessage(
|
||||
browserOffline
|
||||
? messages.offlineModeNoInternetDescription
|
||||
: messages.offlineModeServerUnavailableDescription,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<ButtonStyled>
|
||||
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
|
||||
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
|
||||
@@ -17,18 +113,18 @@
|
||||
v-if="accounts.length === 0"
|
||||
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
|
||||
>
|
||||
<span>{{ formatMessage(messages.notSignedIn) }}</span>
|
||||
<ButtonStyled v-if="!offline" color="brand">
|
||||
<button color="primary" :disabled="loginDisabled" @click="login()">
|
||||
<span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
|
||||
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
|
||||
<button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||
<LogInIcon v-if="!loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
{{ formatMessage(messages.signInToMinecraft) }}
|
||||
{{ formatMessage(messages.signInToStarlight) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="!offline">
|
||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||
<button :disabled="loginDisabled" @click="login()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
||||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -68,6 +164,7 @@
|
||||
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
|
||||
<button
|
||||
class="flex items-center flex-shrink flex-grow overflow-clip gap-2 p-2 border-0 bg-transparent cursor-pointer button-base min-w-0"
|
||||
:disabled="accountSelectionPending"
|
||||
@click="setAccount(account)"
|
||||
>
|
||||
<RadioButtonCheckedIcon
|
||||
@@ -125,6 +222,7 @@
|
||||
<CopyIcon />
|
||||
</button>
|
||||
<button
|
||||
v-if="!account.skin_site_user"
|
||||
v-tooltip="formatMessage(messages.removeAccount)"
|
||||
type="button"
|
||||
class="button-base border-0 bg-transparent p-1.5 cursor-pointer text-secondary hover:text-red"
|
||||
@@ -142,109 +240,11 @@
|
||||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
|
||||
<ModalWrapper ref="yggdrasilAccountModal" :header="formatMessage(messages.thirdPartyModalTitle)">
|
||||
<div class="flex min-w-[24rem] flex-col gap-4">
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.thirdPartyModalDescription) }}</p>
|
||||
<div v-if="savedYggdrasilLogins.length > 0" class="flex flex-col gap-2">
|
||||
<span class="font-semibold">{{ formatMessage(messages.savedLogins) }}</span>
|
||||
<div
|
||||
v-for="savedLogin in savedYggdrasilLogins"
|
||||
:key="`${savedLogin.api_root}:${savedLogin.login}`"
|
||||
class="flex items-center gap-1 rounded-xl bg-surface-3 p-1"
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-grow flex-col items-start border-0 bg-transparent px-3 py-2 text-left cursor-pointer"
|
||||
:disabled="loginDisabled"
|
||||
@click="selectSavedYggdrasilLogin(savedLogin)"
|
||||
>
|
||||
<span class="w-full truncate font-semibold text-primary">{{ savedLogin.login }}</span>
|
||||
<span class="w-full truncate text-xs text-secondary">{{ savedLogin.api_root }}</span>
|
||||
</button>
|
||||
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.removeSavedLogin)"
|
||||
:disabled="loginDisabled"
|
||||
@click="removeSavedYggdrasilLogin(savedLogin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex flex-col gap-2 font-semibold">
|
||||
{{ formatMessage(messages.apiRootLabel) }}
|
||||
<StyledInput
|
||||
v-model="yggdrasilApiRoot"
|
||||
:disabled="true"
|
||||
readonly
|
||||
:placeholder="formatMessage(messages.apiRootPlaceholder)"
|
||||
inputmode="url"
|
||||
@blur="loadRememberedYggdrasilPassword()"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-2 font-semibold">
|
||||
{{ formatMessage(messages.accountLabel) }}
|
||||
<StyledInput
|
||||
v-model="yggdrasilLogin"
|
||||
:disabled="loginDisabled"
|
||||
:placeholder="formatMessage(messages.accountPlaceholder)"
|
||||
autocomplete="username"
|
||||
@blur="loadRememberedYggdrasilPassword()"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-2 font-semibold">
|
||||
{{ formatMessage(messages.passwordLabel) }}
|
||||
<StyledInput
|
||||
v-model="yggdrasilPassword"
|
||||
type="password"
|
||||
:disabled="loginDisabled"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="addYggdrasilAccount()"
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
v-model="rememberYggdrasilPassword"
|
||||
:disabled="loginDisabled"
|
||||
:label="formatMessage(messages.rememberPassword)"
|
||||
/>
|
||||
<div class="input-group push-right">
|
||||
<ButtonStyled>
|
||||
<button :disabled="loginDisabled" @click="yggdrasilAccountModal?.hide()">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="loginDisabled || !yggdrasilFormValid" @click="addYggdrasilAccount()">
|
||||
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
|
||||
<LogInIcon v-else />
|
||||
{{ formatMessage(messages.signInButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="yggdrasilProfileModal" :header="formatMessage(messages.selectProfileTitle)">
|
||||
<div class="flex min-w-[22rem] flex-col gap-2">
|
||||
<p class="m-0 mb-2 text-secondary">{{ formatMessage(messages.selectProfileDescription) }}</p>
|
||||
<ButtonStyled v-for="profile in pendingYggdrasilProfiles" :key="profile.id" class="w-full">
|
||||
<button :disabled="loginDisabled" @click="selectYggdrasilProfile(profile.id)">
|
||||
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
|
||||
<RadioButtonIcon v-else />
|
||||
{{ profile.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<SkinSiteLoginModal ref="skinSiteLoginModal" @signed-in="onSkinSiteSignedIn" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -262,10 +262,8 @@ import {
|
||||
Accordion,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -275,26 +273,28 @@ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import axolotlLogo from '@/assets/netherstar.png'
|
||||
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import SkinSiteLoginModal from '@/components/ui/skin-site/SkinSiteLoginModal.vue'
|
||||
import {
|
||||
requestSkinSitePlayers,
|
||||
selectedSkinSitePlayerId,
|
||||
selectSkinSitePlayer,
|
||||
skinSitePlayers,
|
||||
skinSitePlayersStatus,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
} from '@/composables/skin-site-session'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
begin_yggdrasil_login,
|
||||
delete_yggdrasil_password,
|
||||
finish_yggdrasil_login,
|
||||
get_default_user,
|
||||
get_yggdrasil_password,
|
||||
list_yggdrasil_saved_logins,
|
||||
login as loginToMinecraft,
|
||||
remove_user,
|
||||
set_default_user,
|
||||
set_yggdrasil_password,
|
||||
users,
|
||||
} from '@/helpers/auth'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { authenticateInstancePlayer } from '@/helpers/instance-player'
|
||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Skin } from '@/helpers/skins'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
@@ -302,9 +302,18 @@ import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { offline, refreshBrowserOffline } = useNetworkStatus()
|
||||
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
|
||||
const queryClient = useQueryClient()
|
||||
const route = useRoute()
|
||||
const skinSiteLoginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
|
||||
|
||||
async function goToSkinSiteLogin() {
|
||||
skinSiteLoginModal.value?.show()
|
||||
}
|
||||
|
||||
function onSkinSiteSignedIn() {
|
||||
void requestSkinSitePlayers().catch(() => {})
|
||||
}
|
||||
const refreshingNetwork = ref(false)
|
||||
|
||||
/**
|
||||
@@ -346,25 +355,44 @@ type MinecraftCredential = {
|
||||
server_name: string
|
||||
login: string
|
||||
}
|
||||
skin_site_user?: string
|
||||
head?: string
|
||||
}
|
||||
|
||||
type YggdrasilProfile = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type SavedYggdrasilLogin = {
|
||||
api_root: string
|
||||
login: string
|
||||
}
|
||||
|
||||
type YggdrasilLoginResult =
|
||||
| { status: 'complete'; credentials: MinecraftCredential }
|
||||
| { status: 'select_profile'; flow_id: string; profiles: YggdrasilProfile[] }
|
||||
|
||||
const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
|
||||
|
||||
const accounts: Ref<MinecraftCredential[]> = ref([])
|
||||
const storedAccounts: Ref<MinecraftCredential[]> = ref([])
|
||||
const accounts = computed(() => {
|
||||
// A site's player list is already authoritative for available choices.
|
||||
// Game credentials are created only when the user selects a player.
|
||||
const byId = new Map(
|
||||
storedAccounts.value.map((account) => [
|
||||
account.profile.id.replaceAll('-', '').toLowerCase(),
|
||||
account,
|
||||
]),
|
||||
)
|
||||
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
|
||||
for (const player of skinSitePlayers.value) {
|
||||
const id = player.uuid.replaceAll('-', '').toLowerCase()
|
||||
const stored = byId.get(id)
|
||||
if (stored && stored.account_type !== 'yggdrasil') continue
|
||||
byId.set(id, {
|
||||
...stored,
|
||||
account_type: 'yggdrasil',
|
||||
profile: { ...stored?.profile, id: stored?.profile.id ?? player.uuid, name: player.name },
|
||||
yggdrasil: {
|
||||
api_root: STARLIGHT_YGGDRASIL_API_ROOT,
|
||||
server_name: 'StarLight',
|
||||
login: skinSiteUser.value.uuid,
|
||||
},
|
||||
skin_site_user: skinSiteUser.value.uuid,
|
||||
head: player.headDataUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort(compareMinecraftAccounts)
|
||||
})
|
||||
const accountSelectionPending = ref(false)
|
||||
const loginDisabled = ref(false)
|
||||
const defaultUser = ref<string | undefined>()
|
||||
const equippedSkin = ref<Skin | null>(null)
|
||||
@@ -376,34 +404,17 @@ let refreshGeneration = 0
|
||||
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let defaultUserUpdateQueue = Promise.resolve()
|
||||
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
|
||||
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
|
||||
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
|
||||
const yggdrasilApiRoot = ref(STARLIGHT_YGGDRASIL_API_ROOT)
|
||||
const yggdrasilLogin = ref('')
|
||||
const yggdrasilPassword = ref('')
|
||||
const rememberYggdrasilPassword = ref(true)
|
||||
const savedYggdrasilLogins = ref<SavedYggdrasilLogin[]>([])
|
||||
const pendingYggdrasilFlowId = ref<string | undefined>()
|
||||
const pendingYggdrasilProfiles = ref<YggdrasilProfile[]>([])
|
||||
const yggdrasilFormValid = computed(
|
||||
() =>
|
||||
yggdrasilApiRoot.value.trim().length > 0 &&
|
||||
yggdrasilLogin.value.trim().length > 0 &&
|
||||
yggdrasilPassword.value.length > 0,
|
||||
)
|
||||
|
||||
function createSkinHeadDataUrl(textureUrl: string) {
|
||||
const escapedTextureUrl = textureUrl
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges"><image href="${escapedTextureUrl}" x="-8" y="-8" width="64" height="64" style="image-rendering:pixelated"/><image href="${escapedTextureUrl}" x="-40" y="-8" width="64" height="64" style="image-rendering:pixelated"/></svg>`
|
||||
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
async function refreshSkinSitePlayers() {
|
||||
await requestSkinSitePlayers().catch(() => {})
|
||||
}
|
||||
|
||||
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture)
|
||||
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
|
||||
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
|
||||
@@ -418,7 +429,7 @@ function hasResolvedAccountHead(account: MinecraftCredential) {
|
||||
}
|
||||
|
||||
function hasMissingAccountHeads() {
|
||||
return accounts.value.some(
|
||||
return storedAccounts.value.some(
|
||||
(account) => account.account_type !== 'offline' && !hasResolvedAccountHead(account),
|
||||
)
|
||||
}
|
||||
@@ -452,7 +463,7 @@ async function refreshValues(headRefreshAttempt = 0) {
|
||||
|
||||
defaultUser.value = selectedUser
|
||||
if (offline.value && selectedUser) {
|
||||
await persistDefaultUser(selectedUser)
|
||||
await persistDefaultUser(selectedUser).catch(handleError)
|
||||
if (generation !== refreshGeneration) return
|
||||
}
|
||||
const userList = await users(offline.value).catch(handleError)
|
||||
@@ -461,7 +472,7 @@ async function refreshValues(headRefreshAttempt = 0) {
|
||||
// MinecraftCredential but the TS types from the Tauri IPC bridge do not
|
||||
// carry this refinement. The double cast is deliberate — the shape is
|
||||
// correct and verified at runtime by the backend.
|
||||
accounts.value = Array.isArray(userList)
|
||||
storedAccounts.value = Array.isArray(userList)
|
||||
? [...(userList as unknown as MinecraftCredential[])].filter(
|
||||
(account) =>
|
||||
account.account_type === 'microsoft' ||
|
||||
@@ -469,8 +480,7 @@ async function refreshValues(headRefreshAttempt = 0) {
|
||||
account.yggdrasil?.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT),
|
||||
)
|
||||
: []
|
||||
accounts.value.sort(compareMinecraftAccounts)
|
||||
await renderAccountHeads(accounts.value, generation)
|
||||
await renderAccountHeads(storedAccounts.value, generation)
|
||||
if (generation !== refreshGeneration) return
|
||||
try {
|
||||
const skins = await get_available_skins()
|
||||
@@ -524,6 +534,12 @@ function setLoginDisabled(value: boolean) {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
accounts,
|
||||
defaultUser,
|
||||
accountSelectionPending,
|
||||
setAccount,
|
||||
getAccountSkin,
|
||||
getAccountAvatarUrl,
|
||||
accountChangeRevision,
|
||||
login,
|
||||
refreshValues,
|
||||
@@ -608,6 +624,7 @@ async function renderAccountHeads(accountList: MinecraftCredential[], generation
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (selectedAccount.value) {
|
||||
if (selectedAccount.value.head) return selectedAccount.value.head
|
||||
const cachedHeadUrl = accountHeadUrlCache.value.get(selectedAccount.value.profile.id)
|
||||
if (cachedHeadUrl) return cachedHeadUrl
|
||||
}
|
||||
@@ -617,10 +634,11 @@ const avatarUrl = computed(() => {
|
||||
return cachedUrl
|
||||
}
|
||||
}
|
||||
return selectedAccount.value ? defaultSteveHeadUrl : axolotlLogo
|
||||
return selectedAccount.value ? undefined : axolotlLogo
|
||||
})
|
||||
|
||||
function getAccountAvatarUrl(account: MinecraftCredential) {
|
||||
if (account.head) return account.head
|
||||
const cachedHeadUrl = accountHeadUrlCache.value.get(account.profile.id)
|
||||
if (cachedHeadUrl) {
|
||||
return cachedHeadUrl
|
||||
@@ -634,29 +652,73 @@ function getAccountAvatarUrl(account: MinecraftCredential) {
|
||||
return cachedUrl
|
||||
}
|
||||
}
|
||||
return defaultSteveHeadUrl
|
||||
return undefined
|
||||
}
|
||||
|
||||
function persistDefaultUser(userId: string) {
|
||||
const update = defaultUserUpdateQueue.then(async () => {
|
||||
await set_default_user(userId).catch(handleError)
|
||||
await set_default_user(userId)
|
||||
})
|
||||
defaultUserUpdateQueue = update.catch(() => {})
|
||||
return update
|
||||
}
|
||||
|
||||
async function setAccount(account: MinecraftCredential) {
|
||||
if (accountSelectionPending.value) return
|
||||
accountSelectionPending.value = true
|
||||
const userId = account.profile.id
|
||||
refreshGeneration += 1
|
||||
defaultUser.value = userId
|
||||
equippedSkin.value = null
|
||||
|
||||
await persistDefaultUser(userId)
|
||||
if (defaultUser.value !== userId) return
|
||||
await refreshValues()
|
||||
if (defaultUser.value === userId) notifyAccountChange()
|
||||
try {
|
||||
if (account.skin_site_user) {
|
||||
await authenticateInstancePlayer({
|
||||
id: userId,
|
||||
name: account.profile.name,
|
||||
account_type: account.account_type,
|
||||
skin_site_user: account.skin_site_user,
|
||||
})
|
||||
}
|
||||
await persistDefaultUser(userId)
|
||||
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()
|
||||
await refreshValues()
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
} finally {
|
||||
accountSelectionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[skinSitePlayers, defaultUser, skinSiteUser, accounts],
|
||||
([availablePlayers, selectedLocalUser]) => {
|
||||
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)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function login() {
|
||||
if (offline.value) return
|
||||
loginDisabled.value = true
|
||||
@@ -678,7 +740,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
|
||||
loginDisabled.value = true
|
||||
try {
|
||||
await setAccount(account)
|
||||
trackEvent('AccountLogIn')
|
||||
} catch (error) {
|
||||
handleSevereError(error)
|
||||
} finally {
|
||||
@@ -686,187 +747,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
|
||||
}
|
||||
}
|
||||
|
||||
async function showYggdrasilAccountModal() {
|
||||
yggdrasilApiRoot.value = STARLIGHT_YGGDRASIL_API_ROOT
|
||||
yggdrasilLogin.value = ''
|
||||
yggdrasilPassword.value = ''
|
||||
rememberYggdrasilPassword.value = true
|
||||
pendingYggdrasilFlowId.value = undefined
|
||||
pendingYggdrasilProfiles.value = []
|
||||
await loadSavedYggdrasilLogins()
|
||||
yggdrasilAccountModal.value?.show()
|
||||
}
|
||||
|
||||
async function loadSavedYggdrasilLogins() {
|
||||
const storedLogins = await list_yggdrasil_saved_logins().catch(handleError)
|
||||
const savedLogins: SavedYggdrasilLogin[] = Array.isArray(storedLogins)
|
||||
? [...storedLogins].filter(
|
||||
(savedLogin) => savedLogin.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT,
|
||||
)
|
||||
: []
|
||||
const savedLoginKeys = new Set(
|
||||
savedLogins.map((savedLogin) => `${savedLogin.api_root}\n${savedLogin.login}`),
|
||||
)
|
||||
|
||||
for (const account of accounts.value) {
|
||||
if (
|
||||
!account.yggdrasil ||
|
||||
account.yggdrasil.api_root.replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT
|
||||
)
|
||||
continue
|
||||
const savedLogin = {
|
||||
api_root: account.yggdrasil.api_root,
|
||||
login: account.yggdrasil.login,
|
||||
}
|
||||
const key = `${savedLogin.api_root}\n${savedLogin.login}`
|
||||
if (savedLoginKeys.has(key)) continue
|
||||
|
||||
try {
|
||||
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
|
||||
if (!password) continue
|
||||
await set_yggdrasil_password(savedLogin.api_root, savedLogin.login, password)
|
||||
savedLogins.push(savedLogin)
|
||||
savedLoginKeys.add(key)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
savedYggdrasilLogins.value = savedLogins.sort((left, right) =>
|
||||
left.login.localeCompare(right.login),
|
||||
)
|
||||
}
|
||||
|
||||
async function selectSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
|
||||
if (loginDisabled.value) return
|
||||
|
||||
loginDisabled.value = true
|
||||
try {
|
||||
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
|
||||
if (!password) {
|
||||
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
|
||||
await loadSavedYggdrasilLogins()
|
||||
return
|
||||
}
|
||||
yggdrasilApiRoot.value = savedLogin.api_root
|
||||
yggdrasilLogin.value = savedLogin.login
|
||||
yggdrasilPassword.value = password
|
||||
rememberYggdrasilPassword.value = true
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
} finally {
|
||||
loginDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
|
||||
if (loginDisabled.value) return
|
||||
|
||||
loginDisabled.value = true
|
||||
try {
|
||||
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
|
||||
savedYggdrasilLogins.value = savedYggdrasilLogins.value.filter(
|
||||
(entry) => entry.api_root !== savedLogin.api_root || entry.login !== savedLogin.login,
|
||||
)
|
||||
if (
|
||||
yggdrasilApiRoot.value === savedLogin.api_root &&
|
||||
yggdrasilLogin.value === savedLogin.login
|
||||
) {
|
||||
yggdrasilPassword.value = ''
|
||||
rememberYggdrasilPassword.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
} finally {
|
||||
loginDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRememberedYggdrasilPassword() {
|
||||
if (
|
||||
!rememberYggdrasilPassword.value ||
|
||||
!yggdrasilApiRoot.value.trim() ||
|
||||
!yggdrasilLogin.value.trim() ||
|
||||
yggdrasilPassword.value
|
||||
)
|
||||
return
|
||||
|
||||
try {
|
||||
const password = await get_yggdrasil_password(
|
||||
yggdrasilApiRoot.value.trim(),
|
||||
yggdrasilLogin.value.trim(),
|
||||
)
|
||||
if (password) yggdrasilPassword.value = password
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function persistYggdrasilPasswordPreference() {
|
||||
try {
|
||||
if (rememberYggdrasilPassword.value) {
|
||||
await set_yggdrasil_password(
|
||||
yggdrasilApiRoot.value.trim(),
|
||||
yggdrasilLogin.value.trim(),
|
||||
yggdrasilPassword.value,
|
||||
)
|
||||
} else {
|
||||
await delete_yggdrasil_password(yggdrasilApiRoot.value.trim(), yggdrasilLogin.value.trim())
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
async function addYggdrasilAccount() {
|
||||
if (!yggdrasilFormValid.value || loginDisabled.value) return
|
||||
if (yggdrasilApiRoot.value.trim().replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT) return
|
||||
|
||||
loginDisabled.value = true
|
||||
try {
|
||||
const result = (await begin_yggdrasil_login(
|
||||
yggdrasilApiRoot.value.trim(),
|
||||
yggdrasilLogin.value.trim(),
|
||||
yggdrasilPassword.value,
|
||||
)) as YggdrasilLoginResult
|
||||
if (result.status === 'complete') {
|
||||
await persistYggdrasilPasswordPreference()
|
||||
yggdrasilAccountModal.value?.hide()
|
||||
await setAccount(result.credentials)
|
||||
trackEvent('YggdrasilAccountAdd')
|
||||
} else {
|
||||
pendingYggdrasilFlowId.value = result.flow_id
|
||||
pendingYggdrasilProfiles.value = result.profiles
|
||||
yggdrasilAccountModal.value?.hide()
|
||||
yggdrasilProfileModal.value?.show()
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
} finally {
|
||||
loginDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectYggdrasilProfile(profileId: string) {
|
||||
if (!pendingYggdrasilFlowId.value || loginDisabled.value) return
|
||||
|
||||
loginDisabled.value = true
|
||||
try {
|
||||
const account = (await finish_yggdrasil_login(
|
||||
pendingYggdrasilFlowId.value,
|
||||
profileId,
|
||||
)) as MinecraftCredential
|
||||
await persistYggdrasilPasswordPreference()
|
||||
yggdrasilProfileModal.value?.hide()
|
||||
await setAccount(account)
|
||||
trackEvent('YggdrasilAccountAdd')
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
} finally {
|
||||
loginDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(account: MinecraftCredential) {
|
||||
await remove_user(account.profile.id).catch(handleError)
|
||||
await refreshValues()
|
||||
@@ -875,7 +755,6 @@ async function logout(account: MinecraftCredential) {
|
||||
} else {
|
||||
notifyAccountChange()
|
||||
}
|
||||
trackEvent('AccountLogOut')
|
||||
}
|
||||
|
||||
async function copyAccountUuid(account: MinecraftCredential) {
|
||||
@@ -886,7 +765,7 @@ async function copyAccountUuid(account: MinecraftCredential) {
|
||||
}
|
||||
}
|
||||
|
||||
const unlisten = await process_listener(async (e) => {
|
||||
const unlisten = await process_listener(async (e: { event: string }) => {
|
||||
if (e.event === 'launched') {
|
||||
await refreshValues()
|
||||
}
|
||||
@@ -902,14 +781,47 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
skinSiteSignedIn: {
|
||||
id: 'minecraft-account.skin-site.signed-in',
|
||||
defaultMessage: 'Signed in to StarLight Skin Site',
|
||||
},
|
||||
skinSiteChecking: {
|
||||
id: 'minecraft-account.skin-site.checking',
|
||||
defaultMessage: 'Checking skin site session…',
|
||||
},
|
||||
skinSiteSyncError: {
|
||||
id: 'minecraft-account.skin-site.sync-error',
|
||||
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
|
||||
},
|
||||
skinSitePlayers: {
|
||||
id: 'minecraft-account.skin-site.players',
|
||||
defaultMessage: 'Skin site players ({count})',
|
||||
},
|
||||
retrySkinSitePlayers: {
|
||||
id: 'minecraft-account.skin-site.players.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
noSkinSitePlayers: {
|
||||
id: 'minecraft-account.skin-site.players.empty',
|
||||
defaultMessage: 'No player profiles are attached to this skin site account.',
|
||||
},
|
||||
skinSitePlayersError: {
|
||||
id: 'minecraft-account.skin-site.players.error',
|
||||
defaultMessage: 'Could not refresh the player list. Previously loaded players are kept.',
|
||||
},
|
||||
offlineMode: {
|
||||
id: 'minecraft-account.offline-mode',
|
||||
defaultMessage: 'Offline mode',
|
||||
},
|
||||
offlineModeDescription: {
|
||||
id: 'minecraft-account.offline-mode.description',
|
||||
offlineModeNoInternetDescription: {
|
||||
id: 'minecraft-account.offline-mode.description.no-internet',
|
||||
defaultMessage:
|
||||
'Only offline accounts are available. You can launch fully downloaded instances.',
|
||||
'It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below.',
|
||||
},
|
||||
offlineModeServerUnavailableDescription: {
|
||||
id: 'minecraft-account.offline-mode.description.server-unavailable',
|
||||
defaultMessage:
|
||||
"Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status.",
|
||||
},
|
||||
refreshNetworkStatus: {
|
||||
id: 'minecraft-account.offline-mode.refresh',
|
||||
@@ -921,11 +833,11 @@ const messages = defineMessages({
|
||||
},
|
||||
addMicrosoftAccount: {
|
||||
id: 'minecraft-account.add-microsoft-account',
|
||||
defaultMessage: 'Add Microsoft account',
|
||||
defaultMessage: 'Add your own Minecraft account',
|
||||
},
|
||||
addThirdPartyAccount: {
|
||||
id: 'minecraft-account.add-third-party-account',
|
||||
defaultMessage: 'Add third-party account',
|
||||
signInToStarlight: {
|
||||
id: 'minecraft-account.sign-in-starlight',
|
||||
defaultMessage: 'Sign in to StarLight Skin Site',
|
||||
},
|
||||
thirdPartyAccount: {
|
||||
id: 'minecraft-account.third-party-account',
|
||||
@@ -935,58 +847,6 @@ const messages = defineMessages({
|
||||
id: 'minecraft-account.third-party-badge',
|
||||
defaultMessage: 'StarLight skin',
|
||||
},
|
||||
thirdPartyModalTitle: {
|
||||
id: 'minecraft-account.third-party-modal.title',
|
||||
defaultMessage: 'Sign in with StarLight skin',
|
||||
},
|
||||
thirdPartyModalDescription: {
|
||||
id: 'minecraft-account.third-party-modal.description',
|
||||
defaultMessage: 'Sign in with your StarLight skin account.',
|
||||
},
|
||||
apiRootLabel: {
|
||||
id: 'minecraft-account.third-party-modal.api-root',
|
||||
defaultMessage: 'StarLight skin API address',
|
||||
},
|
||||
apiRootPlaceholder: {
|
||||
id: 'minecraft-account.third-party-modal.api-root-placeholder',
|
||||
defaultMessage: 'https://skin.starlight.cool/yggdrasil',
|
||||
},
|
||||
accountLabel: {
|
||||
id: 'minecraft-account.third-party-modal.account',
|
||||
defaultMessage: 'Account or email',
|
||||
},
|
||||
accountPlaceholder: {
|
||||
id: 'minecraft-account.third-party-modal.account-placeholder',
|
||||
defaultMessage: 'Enter your account or email',
|
||||
},
|
||||
passwordLabel: {
|
||||
id: 'minecraft-account.third-party-modal.password',
|
||||
defaultMessage: 'Password',
|
||||
},
|
||||
rememberPassword: {
|
||||
id: 'minecraft-account.third-party-modal.remember-password',
|
||||
defaultMessage: 'Save this login on this device',
|
||||
},
|
||||
savedLogins: {
|
||||
id: 'minecraft-account.third-party-modal.saved-logins',
|
||||
defaultMessage: 'Saved logins',
|
||||
},
|
||||
removeSavedLogin: {
|
||||
id: 'minecraft-account.third-party-modal.remove-saved-login',
|
||||
defaultMessage: 'Remove saved login',
|
||||
},
|
||||
signInButton: {
|
||||
id: 'minecraft-account.third-party-modal.sign-in',
|
||||
defaultMessage: 'Sign in',
|
||||
},
|
||||
selectProfileTitle: {
|
||||
id: 'minecraft-account.third-party-profile.title',
|
||||
defaultMessage: 'Select a profile',
|
||||
},
|
||||
selectProfileDescription: {
|
||||
id: 'minecraft-account.third-party-profile.description',
|
||||
defaultMessage: 'Choose the Minecraft profile to use with this account.',
|
||||
},
|
||||
offlineAccount: {
|
||||
id: 'minecraft-account.offline-account',
|
||||
defaultMessage: 'Offline Minecraft account',
|
||||
@@ -1069,10 +929,6 @@ const messages = defineMessages({
|
||||
id: 'minecraft-account.label',
|
||||
defaultMessage: 'Minecraft account',
|
||||
},
|
||||
signInToMinecraft: {
|
||||
id: 'minecraft-account.sign-in',
|
||||
defaultMessage: 'Sign in to Minecraft',
|
||||
},
|
||||
loginTrouble: {
|
||||
id: 'minecraft-login.trouble',
|
||||
defaultMessage: 'Having trouble?',
|
||||
|
||||
@@ -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"
|
||||
@@ -137,6 +154,11 @@
|
||||
<OnlineIndicatorIcon />
|
||||
<span class="mr-auto text-contrast flex items-center gap-2">
|
||||
{{ process.instance.name }}
|
||||
<span class="text-xs text-secondary">{{
|
||||
formatMessage(messages.windowStarted, {
|
||||
time: new Date(process.start_time),
|
||||
})
|
||||
}}</span>
|
||||
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
|
||||
</span>
|
||||
</button>
|
||||
@@ -185,6 +207,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BellIcon,
|
||||
NewspaperIcon,
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
@@ -206,18 +229,21 @@ 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'
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
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'
|
||||
|
||||
@@ -227,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
|
||||
@@ -308,11 +353,13 @@ const notificationCenterShown = ref(false)
|
||||
|
||||
interface RunningProcess {
|
||||
uuid: string
|
||||
start_time: string
|
||||
instance_id: string
|
||||
instance: GameInstance
|
||||
}
|
||||
|
||||
interface LoadingEventPayload {
|
||||
total?: number | null
|
||||
event: LoadingBar['bar_type']
|
||||
loader_uuid: string
|
||||
fraction: number | null
|
||||
@@ -320,6 +367,10 @@ interface LoadingEventPayload {
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
windowStarted: {
|
||||
id: 'app.action-bar.window-started',
|
||||
defaultMessage: 'Started {time, time, medium}',
|
||||
},
|
||||
offline: {
|
||||
id: 'app.action-bar.offline',
|
||||
defaultMessage: 'Offline',
|
||||
@@ -346,7 +397,7 @@ const messages = defineMessages({
|
||||
},
|
||||
stopInstance: {
|
||||
id: 'app.action-bar.stop-instance',
|
||||
defaultMessage: 'Stop instance',
|
||||
defaultMessage: 'Stop window',
|
||||
},
|
||||
viewLogs: {
|
||||
id: 'app.action-bar.view-logs',
|
||||
@@ -360,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',
|
||||
@@ -401,8 +456,9 @@ const refresh = async () => {
|
||||
const processes = ((await getRunningProcesses().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})) ?? []) as Array<{ uuid: string; instance_id: string }>
|
||||
const instanceIds = processes.map((process) => process.instance_id)
|
||||
})) ?? []) as Array<{ uuid: string; instance_id: string; start_time: string }>
|
||||
processes.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
const instanceIds = [...new Set(processes.map((process) => process.instance_id))]
|
||||
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
@@ -420,9 +476,9 @@ const refresh = async () => {
|
||||
}
|
||||
})
|
||||
.filter((process): process is RunningProcess => process !== null)
|
||||
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
|
||||
selectedProcess.value = currentProcesses.value[0]
|
||||
}
|
||||
selectedProcess.value =
|
||||
currentProcesses.value.find((process) => process.uuid === selectedProcess.value?.uuid) ??
|
||||
currentProcesses.value[0]
|
||||
}
|
||||
|
||||
await refresh()
|
||||
@@ -436,12 +492,6 @@ const unlistenProcess = await process_listener(async () => {
|
||||
const stop = async (process: RunningProcess) => {
|
||||
try {
|
||||
await killProcess(process.uuid).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: process.instance.loader,
|
||||
game_version: process.instance.game_version,
|
||||
source: 'AppBar',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
@@ -635,6 +685,7 @@ function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
|
||||
return (
|
||||
loadingBar.bar_type?.type !== 'launcher_update' &&
|
||||
[
|
||||
'hosted_pack_sync',
|
||||
'java_download',
|
||||
'pack_file_download',
|
||||
'pack_download',
|
||||
@@ -661,8 +712,8 @@ function applyLoadingEvent(payload: LoadingEventPayload): boolean {
|
||||
const loadingBar = formatLoadingBars({
|
||||
loading_bar_uuid: payload.loader_uuid,
|
||||
message: payload.message,
|
||||
current: payload.fraction,
|
||||
total: 1,
|
||||
current: payload.fraction * (payload.total ?? 1),
|
||||
total: payload.total ?? 1,
|
||||
bar_type: payload.event,
|
||||
})
|
||||
if (!isVisibleLoadingBar(loadingBar)) return false
|
||||
|
||||
@@ -23,7 +23,6 @@ import { computed, ref } from 'vue'
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { AxolotlBrandConfig } from '@/config'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { install_existing_instance } from '@/helpers/install'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
@@ -241,7 +240,6 @@ async function loginMinecraft() {
|
||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||
}
|
||||
|
||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||
loadingMinecraft.value = false
|
||||
errorModal.value.hide()
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { join } from '@tauri-apps/api/path'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { readDir, stat } from '@tauri-apps/plugin-fs'
|
||||
import { ref } from 'vue'
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
<script setup>
|
||||
import {
|
||||
DownloadIcon,
|
||||
GameIcon,
|
||||
PlayIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { DownloadIcon, GameIcon, PlayIcon, SpinnerIcon, TimerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
@@ -23,11 +16,11 @@ import { useRouter } from 'vue-router'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
||||
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
@@ -37,6 +30,7 @@ const formatRelativeTime = useRelativeTime()
|
||||
const { formatMessage } = useVIntl()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const messages = defineMessages({
|
||||
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
|
||||
loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' },
|
||||
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
|
||||
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
|
||||
@@ -86,13 +80,8 @@ const isPlaying = computed(() => props.playing ?? internalPlaying.value)
|
||||
const displayIcon = computed(() =>
|
||||
getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader),
|
||||
)
|
||||
const loading = ref(false)
|
||||
const modLoading = computed(
|
||||
() =>
|
||||
loading.value ||
|
||||
currentEvent.value === 'installing' ||
|
||||
(currentEvent.value === 'launched' && !isPlaying.value),
|
||||
)
|
||||
const loading = computed(() => isInstanceLaunching(props.instance.id))
|
||||
const modLoading = computed(() => loading.value || installing.value)
|
||||
const installing = computed(() => props.instance.install_stage.includes('installing'))
|
||||
const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
|
||||
@@ -106,41 +95,26 @@ const checkProcess = async () => {
|
||||
if (props.playing !== undefined) return
|
||||
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
|
||||
|
||||
internalPlaying.value = runningProcesses.length > 0
|
||||
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
const play = async (e, context) => {
|
||||
const play = async (e) => {
|
||||
e?.stopPropagation()
|
||||
loading.value = true
|
||||
await run(props.instance.id)
|
||||
.catch(async (err) => {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: props.instance.id,
|
||||
instance_name: props.instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||
if (loading.value) return
|
||||
await run(props.instance.id).catch(async (err) => {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: props.instance.id,
|
||||
instance_name: props.instance.name,
|
||||
})
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
})
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const stop = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
internalPlaying.value = false
|
||||
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||
})
|
||||
await checkProcess()
|
||||
}
|
||||
|
||||
const stop = async (e) => {
|
||||
e?.stopPropagation()
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
await checkProcess()
|
||||
}
|
||||
|
||||
const repair = async (e) => {
|
||||
@@ -182,16 +156,11 @@ defineExpose({
|
||||
instance: props.instance,
|
||||
})
|
||||
|
||||
const currentEvent = ref(null)
|
||||
|
||||
const unlisten =
|
||||
props.playing === undefined
|
||||
? await process_listener((e) => {
|
||||
if (e.instance_id === props.instance.id) {
|
||||
currentEvent.value = e.event
|
||||
if (e.event === 'finished') {
|
||||
internalPlaying.value = false
|
||||
}
|
||||
void checkProcess()
|
||||
}
|
||||
})
|
||||
: () => undefined
|
||||
@@ -223,15 +192,7 @@ onUnmounted(() => unlisten())
|
||||
<span class="line-clamp-2">{{ instance.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ButtonStyled v-if="isPlaying" color="red" circular @mousehover="checkProcess">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.stopButton)"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="modLoading" color="standard" circular>
|
||||
<ButtonStyled v-if="modLoading" color="standard" circular>
|
||||
<button v-tooltip="formatMessage(messages.loading)" disabled>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</button>
|
||||
@@ -241,10 +202,10 @@ onUnmounted(() => unlisten())
|
||||
v-tooltip="
|
||||
offline && !installed
|
||||
? formatMessage(messages.offlineInstalledOnly)
|
||||
: formatMessage(commonMessages.playButton)
|
||||
: formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
|
||||
"
|
||||
:disabled="offline && !installed"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@click="play"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<!-- Translate for optical centering -->
|
||||
@@ -301,17 +262,8 @@ onUnmounted(() => unlisten())
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute bottom-1.5 right-1.5 flex size-12 items-center justify-center">
|
||||
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.stopButton)"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="!modLoading && !installing && !installed"
|
||||
v-if="!modLoading && !installing && !installed"
|
||||
size="large"
|
||||
color="brand"
|
||||
circular
|
||||
@@ -335,13 +287,15 @@ onUnmounted(() => unlisten())
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.playButton)"
|
||||
v-tooltip="
|
||||
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
|
||||
"
|
||||
:class="{
|
||||
'pointer-events-none scale-75 opacity-0': disabled,
|
||||
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
|
||||
!disabled,
|
||||
}"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@click="play"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<PlayIcon class="translate-x-[1px]" />
|
||||
@@ -380,19 +334,8 @@ onUnmounted(() => unlisten())
|
||||
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.stopButton)"
|
||||
:class="{ 'scale-100 opacity-100': isPlaying }"
|
||||
class="transition-all origin-bottom opacity-0 card-shadow"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<SpinnerIcon
|
||||
v-else-if="modLoading || installing"
|
||||
v-if="modLoading || installing"
|
||||
v-tooltip="
|
||||
modLoading
|
||||
? formatMessage(messages.loading)
|
||||
@@ -417,7 +360,9 @@ onUnmounted(() => unlisten())
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.playButton)"
|
||||
v-tooltip="
|
||||
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
|
||||
"
|
||||
:class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
|
||||
@@ -59,7 +59,6 @@ import {
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { java_discovery_listener } from '@/helpers/events'
|
||||
import { find_filtered_jres } from '@/helpers/jre.js'
|
||||
|
||||
@@ -131,9 +130,5 @@ const emit = defineEmits(['submit'])
|
||||
function setJavaInstall(javaInstall) {
|
||||
emit('submit', javaInstall)
|
||||
detectJavaModal.value.hide()
|
||||
trackEvent('JavaAutoDetect', {
|
||||
path: javaInstall.path,
|
||||
version: javaInstall.version,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -112,7 +112,6 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
import useJavaTest from '@/composables/useJavaTest'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
@@ -196,9 +195,9 @@ const recommendedInstalled = computed(() => {
|
||||
let hasInitialized = false
|
||||
|
||||
async function runTest(path) {
|
||||
await testJavaInstallation(path, testVersion.value, true)
|
||||
await testJavaInstallation(path, testVersion.value)
|
||||
if (props.version != null) {
|
||||
await recommendedJavaTest.testJavaInstallation(path, props.version, false)
|
||||
await recommendedJavaTest.testJavaInstallation(path, props.version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,9 +211,9 @@ watch(
|
||||
(newPath) => {
|
||||
if (newPath) {
|
||||
if (!hasInitialized) {
|
||||
testJavaInstallation(newPath, testVersion.value, false)
|
||||
testJavaInstallation(newPath, testVersion.value)
|
||||
if (props.version != null) {
|
||||
recommendedJavaTest.testJavaInstallation(newPath, props.version, false)
|
||||
recommendedJavaTest.testJavaInstallation(newPath, props.version)
|
||||
}
|
||||
hasInitialized = true
|
||||
} else {
|
||||
@@ -242,9 +241,6 @@ async function handleJavaFileInput() {
|
||||
}
|
||||
}
|
||||
|
||||
trackEvent('JavaManualSelect', {
|
||||
version: props.version,
|
||||
})
|
||||
|
||||
commitSelection(result)
|
||||
}
|
||||
@@ -279,7 +275,6 @@ async function reinstallJava() {
|
||||
}
|
||||
}
|
||||
|
||||
trackEvent('JavaReInstall', { path: path, version: props.version })
|
||||
commitSelection(result)
|
||||
runTest(result.path)
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
<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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
})
|
||||
</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'"
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,274 @@
|
||||
type Point = [number, number]
|
||||
type Quad = [Point, Point, Point, Point]
|
||||
type ShieldSurface = { plane: Quad; outline?: Point[]; shade?: number }
|
||||
|
||||
// Coordinates follow the sharp master with its localized shoulder cleanup,
|
||||
// normalized to the scene's 2048 × 682⅔ plate.
|
||||
// Each visible bone has its own face: the air between ribs must never receive armor.
|
||||
export const witherShieldSurfaces: ShieldSurface[] = [
|
||||
// Only the upper chest remains; the removed side beams have no shield surfaces.
|
||||
{
|
||||
plane: [
|
||||
[1194, 479],
|
||||
[1294, 489],
|
||||
[1283, 516],
|
||||
[1183, 505],
|
||||
],
|
||||
},
|
||||
// Far central skull: face, jaw wall and right side share the same edges.
|
||||
{
|
||||
plane: [
|
||||
[1162, 355],
|
||||
[1319, 352],
|
||||
[1267, 446],
|
||||
[1108, 442],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1108, 442],
|
||||
[1267, 446],
|
||||
[1280, 489],
|
||||
[1119, 470],
|
||||
],
|
||||
shade: 0.68,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1319, 352],
|
||||
[1356, 443],
|
||||
[1319, 492],
|
||||
[1267, 446],
|
||||
],
|
||||
shade: 0.6,
|
||||
},
|
||||
// Near-left skull, underneath the boot.
|
||||
{
|
||||
plane: [
|
||||
[794, 477],
|
||||
[968, 454],
|
||||
[1003, 549],
|
||||
[833, 602],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[794, 477],
|
||||
[833, 602],
|
||||
[821, 625],
|
||||
[781, 539],
|
||||
],
|
||||
shade: 0.55,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[833, 602],
|
||||
[1003, 549],
|
||||
[983, 587],
|
||||
[821, 625],
|
||||
],
|
||||
shade: 0.65,
|
||||
},
|
||||
// Right skull.
|
||||
{
|
||||
plane: [
|
||||
[1482, 399],
|
||||
[1639, 469],
|
||||
[1568, 582],
|
||||
[1402, 507],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1639, 469],
|
||||
[1650, 557],
|
||||
[1597, 631],
|
||||
[1568, 582],
|
||||
],
|
||||
shade: 0.57,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1402, 507],
|
||||
[1568, 582],
|
||||
[1597, 631],
|
||||
[1414, 583],
|
||||
],
|
||||
shade: 0.66,
|
||||
outline: [
|
||||
[1402, 507],
|
||||
[1568, 582],
|
||||
[1597, 631],
|
||||
[1561, 637],
|
||||
[1444, 621],
|
||||
[1414, 583],
|
||||
],
|
||||
},
|
||||
// Rear rib, left and right of the chest opening.
|
||||
{
|
||||
plane: [
|
||||
[1021, 505],
|
||||
[1129, 484],
|
||||
[1118, 501],
|
||||
[1009, 520],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1009, 520],
|
||||
[1118, 501],
|
||||
[1112, 515],
|
||||
[1006, 535],
|
||||
],
|
||||
shade: 0.65,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1191, 486],
|
||||
[1300, 510],
|
||||
[1289, 530],
|
||||
[1181, 510],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1289, 530],
|
||||
[1300, 510],
|
||||
[1310, 611],
|
||||
[1296, 621],
|
||||
],
|
||||
shade: 0.6,
|
||||
},
|
||||
// Middle rib and broken sternum: separate profiles preserve the dark gaps.
|
||||
{
|
||||
plane: [
|
||||
[1052, 514],
|
||||
[1159, 519],
|
||||
[1129, 553],
|
||||
[992, 548],
|
||||
],
|
||||
outline: [
|
||||
[1015, 535],
|
||||
[1052, 520],
|
||||
[1094, 522],
|
||||
[1110, 514],
|
||||
[1159, 519],
|
||||
[1129, 553],
|
||||
[992, 548],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[992, 548],
|
||||
[1129, 553],
|
||||
[1137, 575],
|
||||
[987, 567],
|
||||
],
|
||||
shade: 0.6,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1159, 519],
|
||||
[1170, 560],
|
||||
[1137, 575],
|
||||
[1129, 553],
|
||||
],
|
||||
shade: 0.55,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1180, 513],
|
||||
[1289, 551],
|
||||
[1274, 575],
|
||||
[1159, 538],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1274, 575],
|
||||
[1289, 551],
|
||||
[1298, 617],
|
||||
[1279, 632],
|
||||
],
|
||||
shade: 0.62,
|
||||
},
|
||||
// Closest right rib; its downturned end stops above the ground.
|
||||
{
|
||||
plane: [
|
||||
[1157, 542],
|
||||
[1261, 581],
|
||||
[1243, 602],
|
||||
[1141, 564],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1141, 564],
|
||||
[1243, 602],
|
||||
[1255, 641],
|
||||
[1148, 605],
|
||||
],
|
||||
shade: 0.7,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1243, 602],
|
||||
[1261, 581],
|
||||
[1270, 627],
|
||||
[1255, 641],
|
||||
],
|
||||
shade: 0.55,
|
||||
},
|
||||
// Foreground rib and spine.
|
||||
{
|
||||
plane: [
|
||||
[1000, 566],
|
||||
[1140, 581],
|
||||
[1107, 604],
|
||||
[960, 589],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[960, 589],
|
||||
[1107, 604],
|
||||
[1112, 680],
|
||||
[968, 622],
|
||||
],
|
||||
shade: 0.6,
|
||||
outline: [
|
||||
[960, 589],
|
||||
[1107, 604],
|
||||
[1112, 680],
|
||||
[1070, 681],
|
||||
[1066, 619],
|
||||
[968, 611],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1107, 604],
|
||||
[1140, 581],
|
||||
[1160, 669],
|
||||
[1112, 680],
|
||||
],
|
||||
shade: 0.5,
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[986, 611],
|
||||
[1070, 617],
|
||||
[967, 684],
|
||||
[874, 671],
|
||||
],
|
||||
},
|
||||
{
|
||||
plane: [
|
||||
[1070, 617],
|
||||
[1074, 668],
|
||||
[1060, 684],
|
||||
[967, 684],
|
||||
],
|
||||
shade: 0.55,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,953 @@
|
||||
import cloudTextureUrl from '@/assets/about-scene/clouds.png'
|
||||
import moonTextureUrl from '@/assets/about-scene/moon-phases.png'
|
||||
import swordTextureUrl from '@/assets/about-scene/netherite-sword.png'
|
||||
import cleanUrl from '@/assets/about-scene/victory-clean.jpg'
|
||||
import originalUrl from '@/assets/about-scene/victory-master.jpg'
|
||||
import correctedPoseUrl from '@/assets/about-scene/victory-pose.jpg'
|
||||
import removedBeamsUrl from '@/assets/about-scene/wither-beams-removed.png'
|
||||
import rebuiltWitherUrl from '@/assets/about-scene/wither-shoulder-cleanup.png'
|
||||
|
||||
import { witherShieldSurfaces } from './wither-shield'
|
||||
|
||||
type Point = [number, number]
|
||||
type Vertex = [number, number, number]
|
||||
|
||||
/** The approved artwork and item geometry are kept separate from the animation lifecycle. */
|
||||
export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Unable to initialize about-page canvas')
|
||||
const ctx = context
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const original = new Image()
|
||||
const clean = new Image()
|
||||
const correctedPose = new Image()
|
||||
const rebuiltWither = new Image()
|
||||
const removedBeams = new Image()
|
||||
const swordTexture = new Image()
|
||||
const cloudTexture = new Image()
|
||||
const moonTexture = new Image()
|
||||
const W = 2048,
|
||||
H = W / 3,
|
||||
artworkWidth = 2172,
|
||||
artworkHeight = 724,
|
||||
artworkScale = artworkWidth / W,
|
||||
TAU = Math.PI * 2
|
||||
let time = 0,
|
||||
last = 0,
|
||||
paused = false,
|
||||
visible = false,
|
||||
ready = false
|
||||
let frame = 0,
|
||||
destroyed = false
|
||||
const makeLayer = (w = W, h = H) => {
|
||||
const layer = document.createElement('canvas')
|
||||
layer.width = Math.ceil(w)
|
||||
layer.height = Math.ceil(h)
|
||||
return layer
|
||||
}
|
||||
const base = makeLayer(artworkWidth, artworkHeight)
|
||||
const shield = makeLayer(artworkWidth, artworkHeight)
|
||||
const star = makeLayer(180, 180)
|
||||
const starFace = makeLayer(180, 180)
|
||||
const swordFace = makeLayer(128, 128)
|
||||
const grippingFingers = makeLayer(64, 80)
|
||||
let cloudField: ReturnType<typeof makeVanillaCloudField>
|
||||
const buriedBlade: Point[] = [
|
||||
[1088, 526],
|
||||
[1099, 505],
|
||||
[1105, 491],
|
||||
[1116, 490],
|
||||
[1124, 483],
|
||||
[1133, 485],
|
||||
[1142, 478],
|
||||
[1158, 477],
|
||||
[1195, 480],
|
||||
[1212, 534],
|
||||
[1169, 567],
|
||||
[1101, 555],
|
||||
]
|
||||
const wound: Point[] = [
|
||||
[1104, 507],
|
||||
[1116, 490],
|
||||
[1136, 490],
|
||||
[1140, 480],
|
||||
[1156, 477],
|
||||
[1178, 479],
|
||||
[1173, 493],
|
||||
[1157, 505],
|
||||
[1152, 524],
|
||||
[1137, 534],
|
||||
[1126, 523],
|
||||
]
|
||||
const starRows = [
|
||||
'....p....',
|
||||
'...pwp...',
|
||||
'..pwYwp..',
|
||||
'.pwYIYwp.',
|
||||
'pwYISIYwp',
|
||||
'.pwYIYwp.',
|
||||
'..pwYwp..',
|
||||
'...pwp...',
|
||||
'....p....',
|
||||
]
|
||||
const starColors: Record<string, string> = {
|
||||
p: '#b692d0',
|
||||
w: '#f8e6cf',
|
||||
Y: '#ffe3a0',
|
||||
I: '#fff6da',
|
||||
S: '#fffef1',
|
||||
}
|
||||
// Irregular failing-lamp events: brief reignitions, weak sputters, and dark gaps.
|
||||
const shieldEvents = [
|
||||
[0, 0.68, 0.48],
|
||||
[0.87, 0.09, 0.82],
|
||||
[1.05, 0.16, 0.27],
|
||||
[1.39, 0.08, 0.61],
|
||||
[2.7, 0.42, 0.2],
|
||||
[3.27, 0.13, 0.65],
|
||||
[4.8, 0.82, 0.42],
|
||||
[5.83, 0.06, 0.82],
|
||||
[6.08, 0.13, 0.29],
|
||||
[6.39, 0.09, 0.7],
|
||||
[8.15, 0.53, 0.26],
|
||||
[9.64, 0.12, 0.69],
|
||||
[9.88, 0.19, 0.34],
|
||||
[10.2, 0.08, 0.73],
|
||||
[11.75, 0.7, 0.45],
|
||||
[12.8, 0.07, 0.87],
|
||||
[13.14, 0.1, 0.24],
|
||||
[15.02, 0.28, 0.5],
|
||||
[15.58, 0.09, 0.86],
|
||||
[15.82, 0.13, 0.31],
|
||||
[17.5, 0.84, 0.22],
|
||||
[18.63, 0.08, 0.68],
|
||||
[18.85, 0.18, 0.39],
|
||||
[20.25, 0.56, 0.53],
|
||||
[21.13, 0.1, 0.86],
|
||||
[21.36, 0.12, 0.35],
|
||||
[22.17, 0.29, 0.18],
|
||||
]
|
||||
const rand = (n: number) => {
|
||||
const v = Math.sin(n * 127.1 + 311.7) * 43758.5453
|
||||
return v - Math.floor(v)
|
||||
}
|
||||
const sparks = Array.from({ length: 34 }, (_, i) => ({
|
||||
seed: i,
|
||||
phase: rand(i),
|
||||
life: 6,
|
||||
size: 2 + rand(i + 9) * 5,
|
||||
}))
|
||||
const dust = Array.from({ length: 56 }, (_, i) => ({
|
||||
seed: i + 80,
|
||||
phase: rand(i + 31),
|
||||
life: 6 + rand(i + 42) * 5,
|
||||
size: 4 + rand(i + 64) * 7,
|
||||
}))
|
||||
const armor: Point[][] = [
|
||||
[
|
||||
[655, 22],
|
||||
[809, 72],
|
||||
[885, 81],
|
||||
[858, 132],
|
||||
[773, 119],
|
||||
[690, 132],
|
||||
[651, 112],
|
||||
],
|
||||
[
|
||||
[693, 214],
|
||||
[768, 223],
|
||||
[805, 385],
|
||||
[727, 398],
|
||||
[712, 301],
|
||||
],
|
||||
[
|
||||
[627, 157],
|
||||
[670, 171],
|
||||
[649, 237],
|
||||
[596, 221],
|
||||
],
|
||||
[
|
||||
[877, 183],
|
||||
[918, 210],
|
||||
[899, 244],
|
||||
[865, 224],
|
||||
],
|
||||
]
|
||||
function path(points: Point[]) {
|
||||
ctx.beginPath()
|
||||
points.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
|
||||
ctx.closePath()
|
||||
}
|
||||
function glow(x: number, y: number, r: number, color: string, alpha: number) {
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r)
|
||||
gradient.addColorStop(0, `rgba(${color},${alpha})`)
|
||||
gradient.addColorStop(0.35, `rgba(${color},${alpha * 0.36})`)
|
||||
gradient.addColorStop(1, `rgba(${color},0)`)
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x - r, y - r, r * 2, r * 2)
|
||||
}
|
||||
function prepare() {
|
||||
// Restore the sharp master for all unaffected materials, anatomy and terrain.
|
||||
const b = base.getContext('2d')!
|
||||
b.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
|
||||
b.imageSmoothingEnabled = false
|
||||
b.drawImage(original, 0, 0, W, H)
|
||||
const sky = makeLayer(artworkWidth, artworkHeight),
|
||||
skyCtx = sky.getContext('2d')!
|
||||
skyCtx.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
|
||||
skyCtx.drawImage(clean, 0, 0, W, H)
|
||||
skyCtx.globalCompositeOperation = 'destination-in'
|
||||
const skyFade = skyCtx.createLinearGradient(0, 409, 0, 448)
|
||||
skyFade.addColorStop(0, '#fff')
|
||||
skyFade.addColorStop(1, 'transparent')
|
||||
skyCtx.fillStyle = skyFade
|
||||
skyCtx.fillRect(0, 0, W, 442)
|
||||
b.drawImage(sky, 0, 0, W, H)
|
||||
const restore = (source: HTMLImageElement, polygon: Point[]) => {
|
||||
b.save()
|
||||
b.beginPath()
|
||||
polygon.forEach((p, i) => (i ? b.lineTo(...p) : b.moveTo(...p)))
|
||||
b.closePath()
|
||||
b.clip()
|
||||
if (source === correctedPose)
|
||||
b.drawImage(source, (583 * W) / 2172, (212 * W) / 2172, (510 * W) / 2172, (477 * W) / 2172)
|
||||
else b.drawImage(source, 0, 0, W, H)
|
||||
b.restore()
|
||||
}
|
||||
// Crisp original character; only previously corrected hands/boots use their local plate.
|
||||
restore(original, [
|
||||
[687, 8],
|
||||
[906, 81],
|
||||
[884, 173],
|
||||
[919, 183],
|
||||
[923, 209],
|
||||
[876, 242],
|
||||
[854, 273],
|
||||
[862, 314],
|
||||
[918, 308],
|
||||
[938, 385],
|
||||
[950, 424],
|
||||
[930, 480],
|
||||
[817, 480],
|
||||
[805, 399],
|
||||
[747, 399],
|
||||
[720, 491],
|
||||
[627, 502],
|
||||
[651, 423],
|
||||
[625, 452],
|
||||
[590, 437],
|
||||
[574, 410],
|
||||
[594, 386],
|
||||
[572, 374],
|
||||
[577, 341],
|
||||
[574, 331],
|
||||
[593, 270],
|
||||
[605, 237],
|
||||
[588, 237],
|
||||
[607, 159],
|
||||
[650, 139],
|
||||
])
|
||||
// Remove the baked angled moon locally with matching moon-free sky.
|
||||
// The new vanilla moon is a separate, camera-facing layer.
|
||||
const moonSky = makeLayer(280, 270),
|
||||
ms = moonSky.getContext('2d')!
|
||||
ms.drawImage(
|
||||
clean,
|
||||
0,
|
||||
0,
|
||||
(280 * clean.naturalWidth) / W,
|
||||
(270 * clean.naturalHeight) / H,
|
||||
0,
|
||||
0,
|
||||
280,
|
||||
270,
|
||||
)
|
||||
ms.globalCompositeOperation = 'destination-in'
|
||||
ms.save()
|
||||
ms.translate(140, 130)
|
||||
ms.scale(1, 1.13)
|
||||
const moonMask = ms.createRadialGradient(0, 0, 80, 0, 0, 123)
|
||||
moonMask.addColorStop(0, '#fff')
|
||||
moonMask.addColorStop(1, 'transparent')
|
||||
ms.fillStyle = moonMask
|
||||
ms.fillRect(-140, -135, 280, 270)
|
||||
ms.restore()
|
||||
b.drawImage(moonSky, 193, -20)
|
||||
// Restore the sharp skull planes covered by the sky plate.
|
||||
restore(original, [
|
||||
[1159, 355],
|
||||
[1318, 352],
|
||||
[1358, 421],
|
||||
[1315, 498],
|
||||
[1246, 474],
|
||||
[1107, 443],
|
||||
])
|
||||
// Remove the old painted blade without resampling the rest of the creature.
|
||||
restore(clean, [
|
||||
[975, 339],
|
||||
[1002, 334],
|
||||
[1078, 369],
|
||||
[1148, 456],
|
||||
[1185, 469],
|
||||
[1200, 481],
|
||||
[1178, 520],
|
||||
[1144, 562],
|
||||
[1080, 570],
|
||||
[1025, 548],
|
||||
[1053, 502],
|
||||
[1006, 476],
|
||||
])
|
||||
// Retain the previously approved removal of baked lightning below the skulls.
|
||||
restore(clean, [
|
||||
[900, 595],
|
||||
[948, 572],
|
||||
[1000, 588],
|
||||
[982, 615],
|
||||
[914, 630],
|
||||
[877, 625],
|
||||
])
|
||||
restore(clean, [
|
||||
[1380, 420],
|
||||
[1428, 430],
|
||||
[1400, 492],
|
||||
[1375, 535],
|
||||
[1356, 606],
|
||||
[1377, 626],
|
||||
[1294, 639],
|
||||
[1284, 579],
|
||||
[1318, 525],
|
||||
[1320, 470],
|
||||
])
|
||||
// Use regeneration only to erase the two unwanted shoulder slabs. The skulls,
|
||||
// ribs, character and background retain their original sharp source pixels.
|
||||
restore(rebuiltWither, [
|
||||
[963, 423],
|
||||
[1014, 417],
|
||||
[1049, 431],
|
||||
[1074, 479],
|
||||
[1066, 495],
|
||||
[1017, 511],
|
||||
[985, 524],
|
||||
[963, 466],
|
||||
])
|
||||
restore(rebuiltWither, [
|
||||
[1360, 415],
|
||||
[1392, 414],
|
||||
[1429, 427],
|
||||
[1448, 445],
|
||||
[1405, 506],
|
||||
[1398, 532],
|
||||
[1355, 528],
|
||||
[1319, 514],
|
||||
[1324, 494],
|
||||
])
|
||||
// Replace only the removed beam silhouettes with the newly exposed background.
|
||||
// No full-frame regeneration or feathering: unaffected source pixels stay intact.
|
||||
restore(removedBeams, [
|
||||
[976, 480],
|
||||
[1146, 462],
|
||||
[1152, 488],
|
||||
[1124, 508],
|
||||
[999, 530],
|
||||
[990, 542],
|
||||
])
|
||||
restore(removedBeams, [
|
||||
[1294, 489],
|
||||
[1404, 498],
|
||||
[1445, 548],
|
||||
[1452, 629],
|
||||
[1402, 621],
|
||||
[1305, 615],
|
||||
[1299, 549],
|
||||
[1283, 521],
|
||||
])
|
||||
restore(correctedPose, [
|
||||
[811, 377],
|
||||
[951, 377],
|
||||
[975, 415],
|
||||
[987, 481],
|
||||
[952, 496],
|
||||
[813, 486],
|
||||
])
|
||||
restore(correctedPose, [
|
||||
[591, 486],
|
||||
[744, 495],
|
||||
[757, 650],
|
||||
[550, 650],
|
||||
[565, 553],
|
||||
])
|
||||
restore(correctedPose, [
|
||||
[880, 210],
|
||||
[925, 218],
|
||||
[951, 235],
|
||||
[977, 245],
|
||||
[979, 280],
|
||||
[957, 302],
|
||||
[932, 299],
|
||||
[910, 280],
|
||||
[877, 267],
|
||||
])
|
||||
try {
|
||||
cloudField = makeVanillaCloudField()
|
||||
} catch (error) {
|
||||
console.warn('Unable to initialize about-page clouds', error)
|
||||
}
|
||||
// Occlude with skin pixels only: background in the palm must not erase the grip.
|
||||
const fingers = grippingFingers.getContext('2d')!
|
||||
fingers.drawImage(
|
||||
clean,
|
||||
(922 * clean.naturalWidth) / W,
|
||||
(228 * clean.naturalHeight) / H,
|
||||
(64 * clean.naturalWidth) / W,
|
||||
(80 * clean.naturalHeight) / H,
|
||||
0,
|
||||
0,
|
||||
64,
|
||||
80,
|
||||
)
|
||||
const skin = fingers.getImageData(0, 0, 64, 80)
|
||||
for (let pixel = 0; pixel < skin.data.length; pixel += 4) {
|
||||
const [red, green, blue] = skin.data.subarray(pixel, pixel + 3)
|
||||
if (red < 90 || green < 48 || red < green * 1.04 || green < blue * 1.08)
|
||||
skin.data[pixel + 3] = 0
|
||||
}
|
||||
fingers.putImageData(skin, 0, 0)
|
||||
// A small extruded item sprite retains sharp pixels during its vertical-axis turn.
|
||||
const s = star.getContext('2d')!
|
||||
starRows.forEach((row, y) =>
|
||||
[...row].forEach((v, x) => {
|
||||
if (v !== '.') {
|
||||
s.fillStyle = starColors[v]
|
||||
s.fillRect(x * 20, y * 20, 20, 20)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
function enchantedFace(
|
||||
target: HTMLCanvasElement,
|
||||
texture: CanvasImageSource,
|
||||
t: number,
|
||||
strength: number,
|
||||
) {
|
||||
const c = target.getContext('2d')!,
|
||||
size = target.width
|
||||
c.clearRect(0, 0, size, size)
|
||||
c.imageSmoothingEnabled = false
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
c.drawImage(texture, 0, 0, size, size)
|
||||
c.globalCompositeOperation = 'source-atop'
|
||||
c.fillStyle = `rgba(123,63,207,${strength * 0.14})`
|
||||
c.fillRect(0, 0, size, size)
|
||||
// Translation of glint only: the texture coordinates and geometry never change.
|
||||
const shift = ((t / 6) % 1) * size * 1.25
|
||||
for (let band = -2; band < 3; band++) {
|
||||
const x = shift + band * size * 1.25
|
||||
const g = c.createLinearGradient(x - size * 0.7, 0, x + size * 0.3, size)
|
||||
g.addColorStop(0, 'rgba(135,72,236,0)')
|
||||
g.addColorStop(0.34, 'rgba(135,72,236,0)')
|
||||
g.addColorStop(0.46, `rgba(163,99,247,${strength * 0.6})`)
|
||||
g.addColorStop(0.5, `rgba(224,178,255,${strength})`)
|
||||
g.addColorStop(0.55, `rgba(151,81,244,${strength * 0.5})`)
|
||||
g.addColorStop(0.66, 'rgba(135,72,236,0)')
|
||||
g.addColorStop(1, 'rgba(135,72,236,0)')
|
||||
c.fillStyle = g
|
||||
c.fillRect(0, 0, size, size)
|
||||
}
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
return target
|
||||
}
|
||||
function drawSword(t: number) {
|
||||
const face = enchantedFace(swordFace, swordTexture, t, 0.34)
|
||||
ctx.save()
|
||||
// Hide only the buried sword pixels; never paint a base-image patch over the shield.
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, W, H)
|
||||
buriedBlade.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
|
||||
ctx.closePath()
|
||||
ctx.clip('evenodd')
|
||||
// Original 128x128 asset is rigidly rotated/scaled, with no mesh warp or repainting.
|
||||
// Place the grip's actual texture center inside the corrected curled palm.
|
||||
ctx.translate(938, 268)
|
||||
ctx.rotate((95.5 * Math.PI) / 180)
|
||||
ctx.scale(2.12, 2.12)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.save()
|
||||
ctx.translate(1.25, 1.25)
|
||||
ctx.filter = 'brightness(.55)'
|
||||
ctx.drawImage(swordTexture, -24, -104, 128, 128)
|
||||
ctx.restore()
|
||||
ctx.drawImage(face, -24, -104, 128, 128)
|
||||
ctx.restore()
|
||||
// The fingers occlude the handle so it remains held, rather than pasted over the hand.
|
||||
ctx.drawImage(grippingFingers, 922, 228)
|
||||
}
|
||||
function drawStar(t: number, angle: number, x: number, y: number) {
|
||||
const front = enchantedFace(starFace, star, t, 0.23)
|
||||
const cosine = Math.cos(angle),
|
||||
sine = Math.sin(angle),
|
||||
halfDepth = 5
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
ctx.imageSmoothingEnabled = false
|
||||
// One native item-texel layer of thickness; keep the front's pixel silhouette intact.
|
||||
const edge = sine > 0 ? -1 : 1
|
||||
for (let row = 0; row < 9; row++)
|
||||
for (let col = 0; col < 9; col++) {
|
||||
const color = starRows[row][col]
|
||||
if (color === '.') continue
|
||||
const neighbor = starRows[row][col + edge]
|
||||
if (neighbor && neighbor !== '.') continue
|
||||
const px = ((col + (edge > 0 ? 1 : 0)) / 9 - 0.5) * 160,
|
||||
py = (row / 9 - 0.5) * 160
|
||||
const a = px * cosine - halfDepth * sine,
|
||||
b = px * cosine + halfDepth * sine
|
||||
ctx.fillStyle = color === 'p' ? '#9070b0' : '#d9af67'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a, py)
|
||||
ctx.lineTo(b, py)
|
||||
ctx.lineTo(b, py + 160 / 9)
|
||||
ctx.lineTo(a, py + 160 / 9)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.translate(Math.sign(cosine) * halfDepth * sine, 0)
|
||||
ctx.scale(cosine, 1)
|
||||
ctx.filter = 'brightness(1.04)'
|
||||
ctx.shadowColor = '#ffdc98'
|
||||
ctx.shadowBlur = 9
|
||||
ctx.drawImage(front, -80, -80, 160, 160)
|
||||
ctx.restore()
|
||||
}
|
||||
function shieldLevel(t: number) {
|
||||
let level = 0
|
||||
for (const [start, duration, power] of shieldEvents) {
|
||||
const p = (t - start) / duration
|
||||
if (p >= 0 && p < 1) {
|
||||
const envelope = Math.min(1, p * 14, (1 - p) * 10)
|
||||
const sputter = 0.64 + 0.36 * rand(Math.floor(t * 19) + start * 91)
|
||||
level = Math.max(level, power * envelope * sputter)
|
||||
}
|
||||
}
|
||||
return level
|
||||
}
|
||||
function makeVanillaCloudField() {
|
||||
// Minecraft 1.20.6's unmodified clouds.png supplies every occupied cell.
|
||||
// One texel is a 12 x 12 block footprint with the vanilla 4-block thickness.
|
||||
const textureCanvas = makeLayer(256, 256),
|
||||
tc = textureCanvas.getContext('2d')!
|
||||
tc.drawImage(cloudTexture, 0, 0)
|
||||
const pixels = tc.getImageData(0, 0, 256, 256).data
|
||||
const occupied = (x: number, z: number) =>
|
||||
pixels[(((z + 256) % 256) * 256 + ((x + 256) % 256)) * 4 + 3] > 127
|
||||
const layer = makeLayer()
|
||||
const context = layer.getContext('webgl', {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
premultipliedAlpha: true,
|
||||
})
|
||||
if (!context) return
|
||||
const gl = context
|
||||
const vertices: number[] = []
|
||||
const quad = (a: Vertex, b: Vertex, c: Vertex, d: Vertex, shade: number) => {
|
||||
for (const point of [a, b, c, a, c, d]) vertices.push(...point, shade)
|
||||
}
|
||||
for (let z = 8; z < 96; z++)
|
||||
for (let x = 0; x < 256; x++) {
|
||||
if (!occupied(x, z + 36)) continue
|
||||
const l = (x - 128) * 12,
|
||||
r = l + 12,
|
||||
n = z * 12,
|
||||
f = n + 12,
|
||||
y = 84,
|
||||
h = y + 4
|
||||
quad([l, y, n], [r, y, n], [r, y, f], [l, y, f], 0.7)
|
||||
if (!occupied(x - 1, z + 36)) quad([l, y, n], [l, y, f], [l, h, f], [l, h, n], 0.9)
|
||||
if (!occupied(x + 1, z + 36)) quad([r, y, f], [r, y, n], [r, h, n], [r, h, f], 0.9)
|
||||
if (!occupied(x, z + 35)) quad([r, y, n], [l, y, n], [l, h, n], [r, h, n], 0.8)
|
||||
if (!occupied(x, z + 37)) quad([l, y, f], [r, y, f], [r, h, f], [l, h, f], 0.8)
|
||||
}
|
||||
const shader = (kind: number, source: string) => {
|
||||
const result = gl.createShader(kind)
|
||||
if (!result) throw new Error('Unable to allocate cloud shader')
|
||||
gl.shaderSource(result, source)
|
||||
gl.compileShader(result)
|
||||
if (!gl.getShaderParameter(result, gl.COMPILE_STATUS))
|
||||
throw new Error(gl.getShaderInfoLog(result) ?? 'Cloud shader compilation failed')
|
||||
return result
|
||||
}
|
||||
const vertex = shader(
|
||||
gl.VERTEX_SHADER,
|
||||
`
|
||||
attribute vec4 vertex; uniform float offset;
|
||||
varying float shade; varying float distance;
|
||||
void main(){
|
||||
vec3 p=vertex.xyz+vec3(offset,0.0,0.0);
|
||||
float focal=650.0; float horizon=350.0;
|
||||
gl_Position=vec4(2.0*focal*p.x/2048.0,
|
||||
(1.0-2.0*horizon/682.6667)*p.z+2.0*focal*p.y/682.6667,
|
||||
1.020202*p.z-28.282828,p.z);
|
||||
shade=vertex.w; distance=p.z;
|
||||
}
|
||||
`,
|
||||
)
|
||||
const fragment = shader(
|
||||
gl.FRAGMENT_SHADER,
|
||||
`
|
||||
precision mediump float; varying float shade; varying float distance;
|
||||
void main(){
|
||||
float alpha=.70*(1.0-smoothstep(480.0,1150.0,distance));
|
||||
gl_FragColor=vec4(vec3(.34,.40,.50)*shade*alpha,alpha);
|
||||
}
|
||||
`,
|
||||
)
|
||||
const program = gl.createProgram()
|
||||
if (!program) throw new Error('Unable to allocate cloud program')
|
||||
gl.attachShader(program, vertex)
|
||||
gl.attachShader(program, fragment)
|
||||
gl.linkProgram(program)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
|
||||
throw new Error(gl.getProgramInfoLog(program) ?? 'Cloud program linking failed')
|
||||
gl.useProgram(program)
|
||||
const buffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)
|
||||
const attribute = gl.getAttribLocation(program, 'vertex')
|
||||
gl.enableVertexAttribArray(attribute)
|
||||
gl.vertexAttribPointer(attribute, 4, gl.FLOAT, false, 16, 0)
|
||||
const offset = gl.getUniformLocation(program, 'offset')
|
||||
gl.enable(gl.DEPTH_TEST)
|
||||
gl.disable(gl.BLEND)
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
gl.viewport(0, 0, layer.width, layer.height)
|
||||
return {
|
||||
draw(elapsed: number) {
|
||||
if (gl.isContextLost()) return
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
|
||||
const travel = (elapsed * 2.4) % 3072
|
||||
for (const tile of [-3072, 0, 3072]) {
|
||||
gl.uniform1f(offset, tile + travel)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 4)
|
||||
}
|
||||
return layer
|
||||
},
|
||||
resize(width: number, height: number) {
|
||||
layer.width = width
|
||||
layer.height = height
|
||||
gl.viewport(0, 0, width, height)
|
||||
},
|
||||
dispose() {
|
||||
gl.deleteBuffer(buffer)
|
||||
gl.deleteProgram(program)
|
||||
gl.deleteShader(vertex)
|
||||
gl.deleteShader(fragment)
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
},
|
||||
}
|
||||
}
|
||||
function drawShield(t: number) {
|
||||
const level = shieldLevel(t)
|
||||
canvas.dataset.shieldIntensity = level.toFixed(3)
|
||||
if (level < 0.003) return
|
||||
const surface = shield.getContext('2d')!
|
||||
surface.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
|
||||
surface.clearRect(0, 0, W, H + 1)
|
||||
surface.save()
|
||||
surface.globalCompositeOperation = 'source-over'
|
||||
const trace = (points: Point[]) => {
|
||||
surface.beginPath()
|
||||
points.forEach((p, i) => (i ? surface.lineTo(...p) : surface.moveTo(...p)))
|
||||
surface.closePath()
|
||||
}
|
||||
// Tight fractured-cavity contour: blue armor remains on the surrounding ribs.
|
||||
surface.beginPath()
|
||||
surface.rect(0, 0, W, H)
|
||||
wound.forEach((p, i) => (i ? surface.lineTo(p[0], p[1]) : surface.moveTo(p[0], p[1])))
|
||||
surface.closePath()
|
||||
surface.clip('evenodd')
|
||||
witherShieldSurfaces.forEach(({ plane: quad, outline = quad, shade = 1 }, index) => {
|
||||
const map = (u: number, v: number): Point => [
|
||||
(1 - v) * ((1 - u) * quad[0][0] + u * quad[1][0]) +
|
||||
v * ((1 - u) * quad[3][0] + u * quad[2][0]),
|
||||
(1 - v) * ((1 - u) * quad[0][1] + u * quad[1][1]) +
|
||||
v * ((1 - u) * quad[3][1] + u * quad[2][1]),
|
||||
]
|
||||
const alpha = level * shade * (0.79 + 0.21 * rand(index + Math.floor(t * 7)))
|
||||
trace(outline)
|
||||
surface.fillStyle = `rgba(55,126,203,${alpha * 0.38})`
|
||||
surface.fill()
|
||||
surface.save()
|
||||
trace(outline)
|
||||
surface.clip()
|
||||
for (let row = 0; row < 8; row++)
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const grain = rand(row * 17 + col * 5 + index * 139)
|
||||
if (grain < 0.56) continue
|
||||
trace([
|
||||
map(col / 8, row / 8),
|
||||
map((col + 1) / 8, row / 8),
|
||||
map((col + 1) / 8, (row + 1) / 8),
|
||||
map(col / 8, (row + 1) / 8),
|
||||
])
|
||||
surface.fillStyle = `rgba(104,167,223,${alpha * (grain - 0.4) * 0.42})`
|
||||
surface.fill()
|
||||
}
|
||||
// Stepped translucent bands wrap each actual surface, like the supplied armor image.
|
||||
for (let band = -1; band < 3; band++)
|
||||
for (let col = 0; col < 8; col++) {
|
||||
const v = band * 0.48 + (t / 24) * 0.48 + Math.floor(rand(col + index * 11) * 3) / 32
|
||||
const height = 0.034 + rand(col * 3 + band + index) * 0.022
|
||||
trace([
|
||||
map(col / 8, v),
|
||||
map((col + 1) / 8, v),
|
||||
map((col + 1) / 8, v + height),
|
||||
map(col / 8, v + height),
|
||||
])
|
||||
surface.fillStyle = `rgba(210,229,172,${alpha * 0.75})`
|
||||
surface.fill()
|
||||
}
|
||||
surface.restore()
|
||||
})
|
||||
surface.restore()
|
||||
// Composite the fitted faces once, without bright overlaps at shared bone edges.
|
||||
ctx.save()
|
||||
ctx.globalCompositeOperation = 'screen'
|
||||
ctx.drawImage(shield, 0, 0, W, H)
|
||||
ctx.restore()
|
||||
}
|
||||
function drawClouds(elapsed: number) {
|
||||
ctx.save()
|
||||
// Clouds pass behind the character silhouette, never across the face or armor.
|
||||
ctx.beginPath()
|
||||
ctx.rect(0, 0, W, H)
|
||||
;[
|
||||
[687, 7],
|
||||
[907, 80],
|
||||
[884, 175],
|
||||
[918, 182],
|
||||
[921, 208],
|
||||
[980, 246],
|
||||
[980, 299],
|
||||
[945, 306],
|
||||
[899, 280],
|
||||
[864, 260],
|
||||
[861, 325],
|
||||
[573, 325],
|
||||
[594, 241],
|
||||
[588, 238],
|
||||
[607, 159],
|
||||
[650, 139],
|
||||
].forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
|
||||
ctx.closePath()
|
||||
ctx.clip('evenodd')
|
||||
const clouds = cloudField?.draw(elapsed)
|
||||
if (clouds) ctx.drawImage(clouds, 0, 0, W, H)
|
||||
ctx.restore()
|
||||
canvas.dataset.cloudOffset = (elapsed * 2.4).toFixed(2)
|
||||
}
|
||||
function drawMoon() {
|
||||
ctx.save()
|
||||
ctx.globalCompositeOperation = 'screen'
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.filter = 'brightness(2.15)'
|
||||
// Vanilla waning-crescent tile, rendered square-on with no skew or side extrusion.
|
||||
// Retain its original pixel shading and native halo; black texels add no light.
|
||||
ctx.drawImage(moonTexture, 96, 0, 32, 32, 45, -179, 576, 576)
|
||||
ctx.restore()
|
||||
}
|
||||
function draw(elapsed: number) {
|
||||
const t = elapsed % 24
|
||||
const ratio = canvas.width / W
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
const cycle = (t * TAU) / 24
|
||||
ctx.save()
|
||||
// Keep the static plate pixel-stable; animate only the independent effects.
|
||||
// The full-resolution master is sampled once, without a floating camera zoom.
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
ctx.drawImage(base, 0, 0, W, H)
|
||||
drawMoon()
|
||||
drawClouds(elapsed)
|
||||
drawShield(t)
|
||||
drawSword(t)
|
||||
const bob = 5.2 * Math.sin(cycle * 3) + 1.3 * Math.sin(cycle * 6 + 0.4)
|
||||
const sx = 1150,
|
||||
sy = 247 + bob
|
||||
const pulse = 0.8 + 0.12 * Math.sin(cycle * 3 + 0.5) + 0.06 * Math.sin(cycle * 7)
|
||||
ctx.save()
|
||||
ctx.globalCompositeOperation = 'screen'
|
||||
glow(sx, sy, 195, '255,195,102', 0.42 * pulse)
|
||||
glow(sx, sy, 88, '255,232,177', 0.15 * pulse)
|
||||
glow(1134, 495, 165, '255,194,101', 0.13 * pulse)
|
||||
glow(790, 191, 90, '255,201,129', 0.055 * pulse)
|
||||
// Narrow rays breathe with the star, instead of rotating the whole scene.
|
||||
ctx.translate(sx, sy)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
ctx.save()
|
||||
ctx.rotate((i * TAU) / 8 + 0.08 * Math.sin(cycle))
|
||||
const g = ctx.createLinearGradient(0, 35, 0, 155)
|
||||
g.addColorStop(0, `rgba(255,228,165,${0.13 * pulse})`)
|
||||
g.addColorStop(1, 'transparent')
|
||||
ctx.fillStyle = g
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-2, 30)
|
||||
ctx.lineTo(-6, 155)
|
||||
ctx.lineTo(6, 155)
|
||||
ctx.lineTo(2, 30)
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
ctx.restore()
|
||||
ctx.save()
|
||||
ctx.globalCompositeOperation = 'screen'
|
||||
for (const polygon of armor) {
|
||||
ctx.save()
|
||||
path(polygon)
|
||||
ctx.clip()
|
||||
const x = ((t / 8) % 1) * 1350 - 240
|
||||
const glint = ctx.createLinearGradient(x - 100, 0, x + 150, 450)
|
||||
glint.addColorStop(0, 'transparent')
|
||||
glint.addColorStop(0.4, 'rgba(160,88,255,0)')
|
||||
glint.addColorStop(0.5, 'rgba(206,149,255,.28)')
|
||||
glint.addColorStop(0.58, 'rgba(136,81,250,.09)')
|
||||
glint.addColorStop(1, 'transparent')
|
||||
ctx.fillStyle = glint
|
||||
ctx.fillRect(550, 0, 650, 520)
|
||||
ctx.restore()
|
||||
}
|
||||
ctx.restore()
|
||||
// Staggered particles have invisible births/deaths, so the loop has no pop.
|
||||
dust.forEach((d) => {
|
||||
const q = (elapsed / d.life + d.phase) % 1,
|
||||
alpha = Math.pow(Math.sin(Math.PI * q), 0.65) * 0.9
|
||||
const x =
|
||||
1145 +
|
||||
(rand(d.seed) - 0.5) * 170 +
|
||||
Math.sin(q * 4 + d.seed) * 24 +
|
||||
q * (45 + rand(d.seed + 2) * 85)
|
||||
const y = 490 - q * (180 + rand(d.seed + 9) * 130)
|
||||
const size = d.size * (1 - q * 0.4)
|
||||
ctx.fillStyle = `rgba(7,10,16,${alpha})`
|
||||
ctx.fillRect(x, y, size, size)
|
||||
if (d.seed % 3 === 0) {
|
||||
ctx.fillRect(x - size * 0.65, y + size * 0.4, size * 0.7, size * 0.65)
|
||||
ctx.fillRect(x + size * 0.65, y - size * 0.5, size * 0.55, size * 0.6)
|
||||
}
|
||||
})
|
||||
ctx.save()
|
||||
ctx.globalCompositeOperation = 'screen'
|
||||
sparks.forEach((d) => {
|
||||
const q = (t / d.life + d.phase) % 1,
|
||||
alpha = Math.pow(Math.sin(Math.PI * q), 1.7) * 0.55
|
||||
const x = 1140 + (rand(d.seed + 7) - 0.5) * 150 + Math.sin(q * 4 + d.seed) * 14
|
||||
const y = 440 - q * (130 + rand(d.seed + 19) * 120)
|
||||
ctx.fillStyle = `rgba(255,207,122,${alpha})`
|
||||
ctx.fillRect(x, y, d.size * 0.45, d.size * 0.45)
|
||||
})
|
||||
ctx.restore()
|
||||
const angle = cycle * 2 + 0.12 * Math.sin(cycle * 2)
|
||||
drawStar(t, angle, sx, sy)
|
||||
ctx.restore()
|
||||
canvas.dataset.animationTime = elapsed.toFixed(3)
|
||||
}
|
||||
|
||||
function canAnimate() {
|
||||
return ready && !destroyed && !paused && visible && !document.hidden && !reducedMotion.matches
|
||||
}
|
||||
function tick(now: number) {
|
||||
frame = 0
|
||||
if (!canAnimate()) return
|
||||
if (last) time += Math.min((now - last) / 1000, 0.1)
|
||||
last = now
|
||||
draw(time)
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
function updatePlayback() {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
frame = 0
|
||||
last = 0
|
||||
if (canAnimate()) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
function resize() {
|
||||
if (destroyed) return
|
||||
const width = canvas.getBoundingClientRect().width
|
||||
if (width <= 0) return
|
||||
canvas.width = Math.min(
|
||||
artworkWidth,
|
||||
Math.max(1, Math.round(width * Math.min(2, devicePixelRatio || 1))),
|
||||
)
|
||||
canvas.height = Math.round(canvas.width / 3)
|
||||
cloudField?.resize(canvas.width, canvas.height)
|
||||
if (ready) draw(time)
|
||||
}
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(canvas)
|
||||
const intersection = new IntersectionObserver((entries) => {
|
||||
visible = entries[0].isIntersecting
|
||||
updatePlayback()
|
||||
})
|
||||
intersection.observe(canvas)
|
||||
document.addEventListener('visibilitychange', updatePlayback)
|
||||
reducedMotion.addEventListener('change', updatePlayback)
|
||||
const loaded = (img: HTMLImageElement, url: string) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error('Unable to load about-page artwork'))
|
||||
img.src = url
|
||||
})
|
||||
const imageAssets = [
|
||||
original,
|
||||
clean,
|
||||
swordTexture,
|
||||
correctedPose,
|
||||
cloudTexture,
|
||||
moonTexture,
|
||||
rebuiltWither,
|
||||
removedBeams,
|
||||
]
|
||||
const loading = Promise.all([
|
||||
loaded(original, originalUrl),
|
||||
loaded(clean, cleanUrl),
|
||||
loaded(swordTexture, swordTextureUrl),
|
||||
loaded(correctedPose, correctedPoseUrl),
|
||||
loaded(cloudTexture, cloudTextureUrl),
|
||||
loaded(moonTexture, moonTextureUrl),
|
||||
loaded(rebuiltWither, rebuiltWitherUrl),
|
||||
loaded(removedBeams, removedBeamsUrl),
|
||||
])
|
||||
.then(() => {
|
||||
if (destroyed) return
|
||||
prepare()
|
||||
ready = true
|
||||
resize()
|
||||
updatePlayback()
|
||||
})
|
||||
.finally(() => {
|
||||
for (const img of imageAssets) {
|
||||
img.onload = null
|
||||
img.onerror = null
|
||||
}
|
||||
})
|
||||
return {
|
||||
ready: loading,
|
||||
setPaused(value: boolean) {
|
||||
paused = value
|
||||
updatePlayback()
|
||||
},
|
||||
dispose() {
|
||||
if (destroyed) return
|
||||
destroyed = true
|
||||
updatePlayback()
|
||||
observer.disconnect()
|
||||
intersection.disconnect()
|
||||
document.removeEventListener('visibilitychange', updatePlayback)
|
||||
reducedMotion.removeEventListener('change', updatePlayback)
|
||||
cloudField?.dispose()
|
||||
cloudField = undefined
|
||||
for (const layer of [base, shield, star, starFace, swordFace, grippingFingers]) {
|
||||
layer.width = 1
|
||||
layer.height = 1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,366 @@
|
||||
<script setup lang="ts">
|
||||
import { NewButton as Button, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onMounted, onScopeDispose, ref, watch } from 'vue'
|
||||
|
||||
import type { Puzzle } from './engine'
|
||||
import { messages } from './messages'
|
||||
|
||||
const props = defineProps<{
|
||||
puzzle: Puzzle
|
||||
revealed: boolean[]
|
||||
selected: number
|
||||
playing: boolean
|
||||
lost: boolean
|
||||
mistake: number
|
||||
zoom: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
paint: [index: number]
|
||||
'update:zoom': [value: number]
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const viewport = ref<HTMLElement>()
|
||||
const grid = ref<HTMLElement>()
|
||||
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
|
||||
const brushCursor = ref<string>()
|
||||
const brushActive = computed(() => props.playing && props.selected >= 0)
|
||||
const large = computed(() => props.puzzle.difficulty !== 'easy')
|
||||
const visibleCells = computed(() =>
|
||||
props.puzzle.answer.flatMap((color, i) =>
|
||||
props.revealed[i] || props.lost ? [{ color, i }] : [],
|
||||
),
|
||||
)
|
||||
const letter = (color: number) => String.fromCharCode(65 + color)
|
||||
|
||||
function updateView() {
|
||||
const el = viewport.value
|
||||
if (!el) return
|
||||
const size = props.puzzle.size
|
||||
view.value = {
|
||||
left: (el.scrollLeft / el.scrollWidth) * size,
|
||||
top: (el.scrollTop / el.scrollHeight) * size,
|
||||
width: Math.min(size, (el.clientWidth / el.scrollWidth) * size),
|
||||
height: Math.min(size, (el.clientHeight / el.scrollHeight) * size),
|
||||
}
|
||||
}
|
||||
|
||||
async function setView(left: number, top: number) {
|
||||
await nextTick()
|
||||
viewport.value?.scrollTo(left, top)
|
||||
updateView()
|
||||
}
|
||||
|
||||
async function centerFirstClue() {
|
||||
await nextTick()
|
||||
const el = viewport.value
|
||||
if (!el || !large.value) return setView(0, 0)
|
||||
const i = props.revealed.findIndex(Boolean)
|
||||
await setView(
|
||||
((i % props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientWidth / 2,
|
||||
(Math.floor(i / props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientHeight / 2,
|
||||
)
|
||||
}
|
||||
|
||||
function navigate(event: MouseEvent) {
|
||||
const rect = (event.currentTarget as SVGElement).getBoundingClientRect()
|
||||
const el = viewport.value
|
||||
if (!el) return
|
||||
void setView(
|
||||
((event.clientX - rect.left) / rect.width) * el.scrollWidth - el.clientWidth / 2,
|
||||
((event.clientY - rect.top) / rect.height) * el.scrollHeight - el.clientHeight / 2,
|
||||
)
|
||||
}
|
||||
|
||||
async function changeZoom(value: number) {
|
||||
const el = viewport.value
|
||||
if (!el) return
|
||||
const zoom = Math.max(0.7, Math.min(1.6, Math.round(value * 10) / 10))
|
||||
const ratio = (40 * zoom + 3) / (40 * props.zoom + 3)
|
||||
const x = (el.scrollLeft + el.clientWidth / 2) * ratio - el.clientWidth / 2
|
||||
const y = (el.scrollTop + el.clientHeight / 2) * ratio - el.clientHeight / 2
|
||||
emit('update:zoom', zoom)
|
||||
await setView(x, y)
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
if (!large.value || !event.ctrlKey) return
|
||||
event.preventDefault()
|
||||
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
|
||||
}
|
||||
|
||||
let cursorColor = ''
|
||||
function updateBrushCursor() {
|
||||
if (!brushActive.value || !grid.value) return
|
||||
const color = getComputedStyle(grid.value).color
|
||||
if (color === cursorColor) return
|
||||
cursorColor = color
|
||||
// Let the native cursor follow the pointer independently of Vue and board rendering.
|
||||
// The hotspot is the same brush tip as the previous 32px floating SVG.
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="${color}" d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"/></svg>`
|
||||
brushCursor.value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 5 27, crosshair`
|
||||
}
|
||||
|
||||
watch(() => [props.playing, props.selected], updateBrushCursor, {
|
||||
flush: 'post',
|
||||
})
|
||||
|
||||
function label(index: number) {
|
||||
const values = {
|
||||
row: Math.floor(index / props.puzzle.size) + 1,
|
||||
column: (index % props.puzzle.size) + 1,
|
||||
}
|
||||
return props.revealed[index] || props.lost
|
||||
? formatMessage(messages.revealed, {
|
||||
...values,
|
||||
color: letter(props.puzzle.answer[index]),
|
||||
number: props.puzzle.numbers[index],
|
||||
})
|
||||
: formatMessage(messages.hidden, values)
|
||||
}
|
||||
|
||||
let observer: ResizeObserver | undefined
|
||||
onMounted(() => {
|
||||
updateBrushCursor()
|
||||
observer = new ResizeObserver(updateView)
|
||||
if (viewport.value) observer.observe(viewport.value)
|
||||
})
|
||||
onScopeDispose(() => observer?.disconnect())
|
||||
defineExpose({
|
||||
centerFirstClue,
|
||||
setView,
|
||||
getView: () => ({
|
||||
left: viewport.value?.scrollLeft ?? 0,
|
||||
top: viewport.value?.scrollTop ?? 0,
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mine-navigation" :class="{ 'mine-large': large }">
|
||||
<div ref="viewport" class="mine-viewport" @scroll.passive="updateView" @wheel="onWheel">
|
||||
<div
|
||||
ref="grid"
|
||||
class="mine-grid"
|
||||
:class="{
|
||||
'mine-grid-small': !large,
|
||||
'mine-grid-brush-active': brushActive,
|
||||
}"
|
||||
:style="{
|
||||
'--mine-size': puzzle.size,
|
||||
'--mine-cell-size': `${40 * zoom}px`,
|
||||
'--mine-brush-cursor': brushCursor,
|
||||
color: selected >= 0 ? `var(--mine-color-${selected})` : undefined,
|
||||
}"
|
||||
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
|
||||
@pointerenter="updateBrushCursor"
|
||||
>
|
||||
<button
|
||||
v-for="(_, i) in puzzle.answer"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="mine-cell"
|
||||
:class="{
|
||||
'mine-open': revealed[i] || lost,
|
||||
'mine-answer': lost && !revealed[i],
|
||||
'mine-mistake': mistake === i,
|
||||
'mine-sampled': revealed[i] && selected === puzzle.answer[i],
|
||||
}"
|
||||
:style="
|
||||
revealed[i] || lost
|
||||
? { '--mine-cell-color': `var(--mine-color-${puzzle.answer[i]})` }
|
||||
: undefined
|
||||
"
|
||||
:disabled="!playing"
|
||||
:aria-label="label(i)"
|
||||
@click="emit('paint', i)"
|
||||
>
|
||||
<template v-if="revealed[i] || lost">
|
||||
<span>{{ puzzle.numbers[i] }}</span
|
||||
><small>{{ letter(puzzle.answer[i]) }}</small>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="large" class="mine-overview">
|
||||
<svg
|
||||
class="mine-map"
|
||||
:viewBox="`0 0 ${puzzle.size} ${puzzle.size}`"
|
||||
role="img"
|
||||
:aria-label="formatMessage(messages.map)"
|
||||
@click="navigate"
|
||||
>
|
||||
<rect width="100%" height="100%" fill="var(--surface-1)" />
|
||||
<rect
|
||||
v-for="cell in visibleCells"
|
||||
:key="cell.i"
|
||||
:x="cell.i % puzzle.size"
|
||||
:y="Math.floor(cell.i / puzzle.size)"
|
||||
width="1"
|
||||
height="1"
|
||||
:fill="`var(--mine-color-${cell.color})`"
|
||||
/>
|
||||
<rect
|
||||
:x="view.left"
|
||||
:y="view.top"
|
||||
:width="view.width"
|
||||
:height="view.height"
|
||||
fill="none"
|
||||
stroke="var(--color-contrast)"
|
||||
stroke-width="2"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<div class="mine-zoom">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="zoom <= 0.7"
|
||||
:aria-label="formatMessage(messages.zoomOut)"
|
||||
@click="changeZoom(zoom - 0.1)"
|
||||
>−</Button
|
||||
>
|
||||
<span>{{ Math.round(zoom * 100) }}%</span>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="zoom >= 1.6"
|
||||
:aria-label="formatMessage(messages.zoomIn)"
|
||||
@click="changeZoom(zoom + 0.1)"
|
||||
>+</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mine-navigation {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: grid;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
.mine-large {
|
||||
grid-template-columns: minmax(0, 1fr) 7rem;
|
||||
}
|
||||
.mine-viewport {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
max-height: min(52vh, 32rem);
|
||||
min-width: 0;
|
||||
background: var(--surface-1);
|
||||
border-radius: var(--radius-sm);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.mine-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--mine-size), var(--mine-cell-size));
|
||||
/* Scrolling and minimap coordinates use a fixed square pitch at every zoom level. */
|
||||
grid-auto-rows: var(--mine-cell-size);
|
||||
gap: 3px;
|
||||
width: max-content;
|
||||
padding: 3px;
|
||||
}
|
||||
.mine-grid-small {
|
||||
grid-template-columns: repeat(var(--mine-size), minmax(0, 1fr));
|
||||
grid-auto-rows: auto;
|
||||
width: min(100%, 26rem, 48vh);
|
||||
margin: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.mine-cell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--mine-ink) 38%, var(--surface-5));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-4);
|
||||
color: var(--color-contrast);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.mine-grid-brush-active,
|
||||
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
|
||||
cursor: var(--mine-brush-cursor, crosshair);
|
||||
}
|
||||
.mine-cell:hover:not(:disabled) {
|
||||
background: var(--surface-5);
|
||||
border-color: var(--mine-ink);
|
||||
}
|
||||
.mine-cell:focus-visible {
|
||||
outline: 2px solid var(--color-contrast);
|
||||
outline-offset: -3px;
|
||||
z-index: 1;
|
||||
}
|
||||
.mine-open {
|
||||
background: color-mix(in srgb, var(--mine-cell-color) 34%, var(--surface-2));
|
||||
border-color: var(--mine-cell-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mine-open:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--mine-cell-color) 50%, var(--surface-2));
|
||||
}
|
||||
.mine-sampled {
|
||||
box-shadow: inset 0 0 0 1px var(--mine-cell-color);
|
||||
}
|
||||
.mine-cell small {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 1px;
|
||||
font-size: 0.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.mine-answer {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.mine-cell:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.mine-mistake {
|
||||
opacity: 1;
|
||||
outline: 3px solid var(--color-red);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
.mine-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
align-items: center;
|
||||
}
|
||||
.mine-map {
|
||||
display: block;
|
||||
width: 7rem;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: crosshair;
|
||||
}
|
||||
.mine-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.mine-large {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.mine-overview {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.mine-map {
|
||||
width: 4.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,489 @@
|
||||
<script setup lang="ts">
|
||||
import './palette.css'
|
||||
|
||||
import { NewButton as Button, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
|
||||
|
||||
import ColorMineBoard from './ColorMineBoard.vue'
|
||||
import { type Difficulty, LEVELS, type Puzzle } from './engine'
|
||||
import { messages } from './messages'
|
||||
import {
|
||||
parseSave,
|
||||
readBest,
|
||||
recordBest,
|
||||
SAVE_KEY,
|
||||
type SavedGame,
|
||||
saveGame,
|
||||
validateResume,
|
||||
} from './storage'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const board = ref<InstanceType<typeof ColorMineBoard>>()
|
||||
const prompt = ref<HTMLElement>()
|
||||
const puzzle = shallowRef<Puzzle>()
|
||||
const revealed = ref<boolean[]>([])
|
||||
const selected = ref(-1)
|
||||
const mistake = ref(-1)
|
||||
const elapsed = ref(0)
|
||||
const zoom = ref(1)
|
||||
const best = ref<number | null>(null)
|
||||
const difficulty = ref<Difficulty>('easy')
|
||||
const levels = Object.keys(LEVELS) as Difficulty[]
|
||||
const screen = ref<'loading' | 'board' | 'saved' | 'error'>('loading')
|
||||
const result = ref<'playing' | 'lost' | 'won'>('playing')
|
||||
const confirmation = ref<'leave' | 'replace' | null>(null)
|
||||
const saved = shallowRef<SavedGame | null>(null)
|
||||
const storageError = ref(false)
|
||||
const recordError = ref(false)
|
||||
const needsColor = ref(false)
|
||||
let nextDifficulty: Difficulty = 'easy'
|
||||
let worker: Worker | undefined
|
||||
let active = false
|
||||
let closing = false
|
||||
let started = false
|
||||
let heldView = { left: 0, top: 0 }
|
||||
let lastTick = performance.now()
|
||||
const count = computed(() => revealed.value.filter(Boolean).length)
|
||||
const letter = (color: number) => String.fromCharCode(65 + color)
|
||||
const timeText = (time: number) => {
|
||||
const seconds = Math.floor(time / 1000)
|
||||
return `${Math.floor(seconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
|
||||
}
|
||||
const status = computed(() => {
|
||||
if (result.value === 'won') return formatMessage(messages.won)
|
||||
if (result.value === 'lost' && puzzle.value)
|
||||
return formatMessage(messages.lost, {
|
||||
row: Math.floor(mistake.value / puzzle.value.size) + 1,
|
||||
column: (mistake.value % puzzle.value.size) + 1,
|
||||
selected: letter(selected.value),
|
||||
correct: letter(puzzle.value.answer[mistake.value]),
|
||||
})
|
||||
return selected.value < 0 || needsColor.value
|
||||
? formatMessage(messages.choose)
|
||||
: formatMessage(messages.selected, { color: letter(selected.value) })
|
||||
})
|
||||
|
||||
function tick() {
|
||||
const now = performance.now()
|
||||
if (
|
||||
active &&
|
||||
started &&
|
||||
screen.value === 'board' &&
|
||||
result.value === 'playing' &&
|
||||
!confirmation.value &&
|
||||
!document.hidden
|
||||
) {
|
||||
elapsed.value += now - lastTick
|
||||
}
|
||||
lastTick = now
|
||||
}
|
||||
let timer: number | undefined
|
||||
function visibilityChanged() {
|
||||
lastTick = performance.now()
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityChanged)
|
||||
|
||||
async function show() {
|
||||
if (active || closing) return
|
||||
active = true
|
||||
lastTick = performance.now()
|
||||
timer = window.setInterval(tick, 250)
|
||||
modal.value?.show()
|
||||
loadSaved()
|
||||
}
|
||||
|
||||
function loadSaved() {
|
||||
storageError.value = false
|
||||
saved.value = null
|
||||
try {
|
||||
saved.value = parseSave(localStorage.getItem(SAVE_KEY))
|
||||
if (saved.value) {
|
||||
difficulty.value = saved.value.difficulty
|
||||
screen.value = 'saved'
|
||||
} else start(difficulty.value)
|
||||
} catch {
|
||||
screen.value = 'saved'
|
||||
storageError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function start(level: Difficulty, resume?: SavedGame) {
|
||||
worker?.terminate()
|
||||
confirmation.value = null
|
||||
storageError.value = false
|
||||
recordError.value = false
|
||||
screen.value = 'loading'
|
||||
difficulty.value = level
|
||||
started = false
|
||||
try {
|
||||
const pending = new Worker(new URL('./generator.worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
worker = pending
|
||||
pending.onerror = () => {
|
||||
if (worker !== pending) return
|
||||
pending.terminate()
|
||||
worker = undefined
|
||||
screen.value = 'error'
|
||||
}
|
||||
pending.onmessage = async (event: MessageEvent<{ puzzle?: Puzzle; error?: boolean }>) => {
|
||||
if (worker !== pending || !active) return
|
||||
pending.terminate()
|
||||
worker = undefined
|
||||
if (!event.data.puzzle) {
|
||||
screen.value = 'error'
|
||||
return
|
||||
}
|
||||
const generated = event.data.puzzle
|
||||
if (resume) {
|
||||
try {
|
||||
validateResume(resume, generated)
|
||||
// Consume the save before playing so losing cannot reload an older, safe state.
|
||||
localStorage.removeItem(SAVE_KEY)
|
||||
} catch {
|
||||
screen.value = 'saved'
|
||||
storageError.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
saved.value = null
|
||||
puzzle.value = generated
|
||||
revealed.value = resume?.revealed.slice() ?? generated.clues.slice()
|
||||
selected.value = resume?.selected ?? -1
|
||||
elapsed.value = resume?.elapsed ?? 0
|
||||
started = elapsed.value > 0
|
||||
lastTick = performance.now()
|
||||
zoom.value = resume?.zoom ?? 1
|
||||
mistake.value = -1
|
||||
needsColor.value = false
|
||||
result.value = 'playing'
|
||||
screen.value = 'board'
|
||||
try {
|
||||
best.value = readBest(localStorage, level)
|
||||
} catch {
|
||||
best.value = null
|
||||
}
|
||||
await nextTick()
|
||||
if (resume) await board.value?.setView(resume.left, resume.top)
|
||||
else await board.value?.centerFirstClue()
|
||||
}
|
||||
pending.postMessage({
|
||||
seed: resume?.seed ?? crypto.getRandomValues(new Uint32Array(1))[0],
|
||||
difficulty: level,
|
||||
})
|
||||
} catch {
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
screen.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function paint(index: number) {
|
||||
const current = puzzle.value
|
||||
if (!current || confirmation.value || result.value !== 'playing') return
|
||||
if (revealed.value[index]) {
|
||||
selected.value = current.answer[index]
|
||||
needsColor.value = false
|
||||
return
|
||||
}
|
||||
if (selected.value < 0) {
|
||||
needsColor.value = true
|
||||
return
|
||||
}
|
||||
tick()
|
||||
started = true
|
||||
if (selected.value !== current.answer[index]) {
|
||||
mistake.value = index
|
||||
result.value = 'lost'
|
||||
return
|
||||
}
|
||||
revealed.value[index] = true
|
||||
if (revealed.value.every(Boolean)) {
|
||||
result.value = 'won'
|
||||
try {
|
||||
best.value = recordBest(localStorage, current.difficulty, elapsed.value)
|
||||
} catch {
|
||||
recordError.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ask(kind: 'leave' | 'replace') {
|
||||
tick()
|
||||
heldView = board.value?.getView() ?? { left: 0, top: 0 }
|
||||
confirmation.value = kind
|
||||
await nextTick()
|
||||
prompt.value?.focus()
|
||||
}
|
||||
|
||||
function requestNew(level: Difficulty = difficulty.value) {
|
||||
nextDifficulty = level
|
||||
if (screen.value === 'board' && result.value === 'playing') void ask('replace')
|
||||
else start(level)
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (confirmation.value) {
|
||||
confirmation.value = null
|
||||
lastTick = performance.now()
|
||||
void nextTick(() => board.value?.setView(heldView.left, heldView.top))
|
||||
return
|
||||
}
|
||||
if (screen.value === 'board' && result.value === 'playing') void ask('leave')
|
||||
else void close()
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (closing) return
|
||||
closing = true
|
||||
active = false
|
||||
window.clearInterval(timer)
|
||||
timer = undefined
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
await modal.value?.hide()
|
||||
confirmation.value = null
|
||||
puzzle.value = undefined
|
||||
closing = false
|
||||
}
|
||||
|
||||
async function saveAndLeave() {
|
||||
if (!puzzle.value) return
|
||||
try {
|
||||
saveGame(localStorage, {
|
||||
version: 1,
|
||||
seed: puzzle.value.seed,
|
||||
difficulty: puzzle.value.difficulty,
|
||||
revealed: revealed.value.slice(),
|
||||
selected: selected.value,
|
||||
elapsed: elapsed.value,
|
||||
zoom: zoom.value,
|
||||
left: heldView.left,
|
||||
top: heldView.top,
|
||||
})
|
||||
await close()
|
||||
} catch {
|
||||
storageError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function discardSaved() {
|
||||
try {
|
||||
localStorage.removeItem(SAVE_KEY)
|
||||
saved.value = null
|
||||
start(difficulty.value)
|
||||
} catch {
|
||||
storageError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
requestClose()
|
||||
}
|
||||
}
|
||||
|
||||
onScopeDispose(() => {
|
||||
worker?.terminate()
|
||||
window.clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', visibilityChanged)
|
||||
})
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="56rem"
|
||||
max-width="56rem"
|
||||
scrollable
|
||||
:closable="false"
|
||||
:close-on-esc="false"
|
||||
actions-divider
|
||||
@keydown.stop="keydown"
|
||||
>
|
||||
<div
|
||||
class="color-mine"
|
||||
:style="{
|
||||
'--mine-ink': selected >= 0 ? `var(--mine-color-${selected})` : 'var(--color-brand)',
|
||||
}"
|
||||
>
|
||||
<div v-if="confirmation" ref="prompt" class="mine-prompt" tabindex="-1" role="alert">
|
||||
<h3>
|
||||
{{
|
||||
formatMessage(confirmation === 'leave' ? messages.leaveTitle : messages.replaceTitle)
|
||||
}}
|
||||
</h3>
|
||||
<p>
|
||||
{{ formatMessage(confirmation === 'leave' ? messages.leaveText : messages.replaceText) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="screen === 'board' && puzzle" v-show="!confirmation" class="mine-play">
|
||||
<div class="mine-toolbar">
|
||||
<div class="mine-levels">
|
||||
<Button
|
||||
v-for="level in levels"
|
||||
:key="level"
|
||||
size="sm"
|
||||
:type="difficulty === level ? 'outlined' : 'base'"
|
||||
:aria-pressed="difficulty === level"
|
||||
@click="level !== difficulty && requestNew(level)"
|
||||
>{{ formatMessage(messages[level]) }}</Button
|
||||
>
|
||||
</div>
|
||||
<span class="mine-time">{{ timeText(elapsed) }}</span>
|
||||
</div>
|
||||
<div class="mine-frame">
|
||||
<p class="mine-status" role="status">{{ status }}</p>
|
||||
<ColorMineBoard
|
||||
ref="board"
|
||||
v-model:zoom="zoom"
|
||||
:puzzle="puzzle"
|
||||
:revealed="revealed"
|
||||
:selected="selected"
|
||||
:playing="result === 'playing'"
|
||||
:lost="result === 'lost'"
|
||||
:mistake="mistake"
|
||||
@paint="paint"
|
||||
/>
|
||||
</div>
|
||||
<div class="mine-stats">
|
||||
<span>{{ formatMessage(messages.progress, { count, total: revealed.length }) }}</span>
|
||||
<span v-if="best !== null">{{
|
||||
formatMessage(messages.best, { time: timeText(best) })
|
||||
}}</span>
|
||||
</div>
|
||||
<p v-if="result === 'lost'" class="mine-meta">{{ formatMessage(messages.review) }}</p>
|
||||
<p v-if="recordError" role="alert">{{ formatMessage(messages.recordError) }}</p>
|
||||
<details class="mine-rules">
|
||||
<summary>{{ formatMessage(messages.rulesTitle) }}</summary>
|
||||
<p>{{ formatMessage(messages.rules) }}</p>
|
||||
</details>
|
||||
</div>
|
||||
<p v-else-if="screen === 'loading'" role="status">{{ formatMessage(messages.loading) }}</p>
|
||||
<div v-else-if="screen === 'saved'" class="mine-prompt">
|
||||
<h3>{{ formatMessage(messages.savedTitle) }}</h3>
|
||||
<p v-if="saved">
|
||||
{{ formatMessage(messages[saved.difficulty]) }} · {{ timeText(saved.elapsed) }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else role="alert">{{ formatMessage(messages.generateError) }}</p>
|
||||
<p v-if="storageError" role="alert">{{ formatMessage(messages.storageError) }}</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="mine-actions">
|
||||
<template v-if="confirmation">
|
||||
<Button @click="requestClose">{{ formatMessage(messages.continue) }}</Button>
|
||||
<Button v-if="confirmation === 'leave'" @click="close">{{
|
||||
formatMessage(messages.discard)
|
||||
}}</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
@click="confirmation === 'leave' ? saveAndLeave() : start(nextDifficulty)"
|
||||
>
|
||||
{{
|
||||
formatMessage(confirmation === 'leave' ? messages.saveLeave : messages.newGame)
|
||||
}}</Button
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button @click="requestClose">{{ formatMessage(messages.close) }}</Button>
|
||||
<template v-if="screen === 'saved'">
|
||||
<Button @click="discardSaved">{{ formatMessage(messages.discard) }}</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
@click="saved ? start(saved.difficulty, saved) : loadSaved()"
|
||||
>
|
||||
{{ formatMessage(saved ? messages.resume : messages.retry) }}</Button
|
||||
>
|
||||
</template>
|
||||
<Button
|
||||
v-else-if="screen !== 'loading'"
|
||||
:type="result === 'playing' && screen === 'board' ? 'base' : 'colored'"
|
||||
color="brand"
|
||||
@click="screen === 'error' && saved ? start(saved.difficulty, saved) : requestNew()"
|
||||
>
|
||||
{{ formatMessage(screen === 'error' ? messages.retry : messages.newGame) }}</Button
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.color-mine {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
min-width: 0;
|
||||
}
|
||||
.mine-play {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
min-width: 0;
|
||||
}
|
||||
.mine-toolbar,
|
||||
.mine-levels,
|
||||
.mine-stats,
|
||||
.mine-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
.mine-time {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
.mine-meta,
|
||||
.mine-stats,
|
||||
.mine-rules {
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.mine-stats {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.mine-frame {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 2px solid var(--mine-ink);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--gap-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.mine-status {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0 0 var(--gap-md);
|
||||
color: var(--color-contrast);
|
||||
font-size: 0.875rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mine-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.mine-prompt h3,
|
||||
.mine-prompt p {
|
||||
margin: 0 0 var(--gap-md);
|
||||
}
|
||||
.mine-rules summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.mine-rules p {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { type Board, createEngine, type Difficulty, generate, LEVELS } from './engine.ts'
|
||||
|
||||
function connected(board: Board) {
|
||||
const { cross } = createEngine(board.size, board.colors)
|
||||
for (let color = 0; color < board.colors; color++) {
|
||||
const source = board.answer.indexOf(color)
|
||||
if (source < 0) return false
|
||||
const visited = new Set([source])
|
||||
const pending = [source]
|
||||
while (pending.length) {
|
||||
for (const neighbor of cross[pending.pop()!]) {
|
||||
if (board.answer[neighbor] === color && !visited.has(neighbor)) {
|
||||
visited.add(neighbor)
|
||||
pending.push(neighbor)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (visited.size !== board.answer.filter((c) => c === color).length) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
for (const difficulty of Object.keys(LEVELS) as Difficulty[]) {
|
||||
test(`${difficulty}: 20 seeds have connected colors, correct numbers and a complete no-guess path`, () => {
|
||||
for (let seed = 1; seed <= 20; seed++) {
|
||||
const puzzle = generate(seed, difficulty)
|
||||
const engine = createEngine(puzzle.size, puzzle.colors)
|
||||
assert.ok(connected(puzzle))
|
||||
assert.ok(puzzle.clues.some((v) => !v))
|
||||
assert.ok(
|
||||
puzzle.clues.filter(Boolean).length >=
|
||||
Math.ceil(puzzle.size ** 2 * LEVELS[difficulty].density),
|
||||
)
|
||||
for (let color = 0; color < puzzle.colors; color++) {
|
||||
assert.ok(puzzle.clues.some((v, i) => v && puzzle.answer[i] === color))
|
||||
}
|
||||
assert.deepEqual(
|
||||
puzzle.numbers,
|
||||
puzzle.answer.map(
|
||||
(color, i) => engine.around[i].filter((j) => puzzle.answer[j] === color).length,
|
||||
),
|
||||
)
|
||||
assert.ok(engine.solve(puzzle, puzzle.clues).every(Boolean))
|
||||
if (difficulty === 'hard') {
|
||||
for (let i = 0; i < puzzle.clues.length; i++) {
|
||||
if (
|
||||
!puzzle.clues[i] ||
|
||||
puzzle.clues.filter((v, j) => v && puzzle.answer[j] === puzzle.answer[i]).length <= 1
|
||||
)
|
||||
continue
|
||||
const fewer = puzzle.clues.slice()
|
||||
fewer[i] = false
|
||||
assert.ok(
|
||||
engine.solve(puzzle, fewer).some((v) => !v),
|
||||
`Redundant clue at ${i}, seed ${seed}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('seeded generation is deterministic for saved games', () => {
|
||||
assert.deepEqual(generate(4294967295, 'normal'), generate(4294967295, 'normal'))
|
||||
})
|
||||
|
||||
test('deductions agree with exhaustive valid 3 × 3 boards; unrevealed numbers never leak', () => {
|
||||
const engine = createEngine(3, 2)
|
||||
const boards: Board[] = []
|
||||
for (let bits = 1; bits < 511; bits++) {
|
||||
const answer = Array.from({ length: 9 }, (_, i) => (bits >> i) & 1)
|
||||
const board = {
|
||||
size: 3,
|
||||
colors: 2,
|
||||
answer,
|
||||
numbers: answer.map((c, i) => engine.around[i].filter((j) => answer[j] === c).length),
|
||||
}
|
||||
if (connected(board)) boards.push(board)
|
||||
}
|
||||
for (const board of boards) {
|
||||
const revealed = board.answer.map((color, i) => i === board.answer.indexOf(color))
|
||||
const masks = engine.infer(board, revealed)
|
||||
const candidates = boards.filter((candidate) =>
|
||||
revealed.every(
|
||||
(open, i) =>
|
||||
!open ||
|
||||
(candidate.answer[i] === board.answer[i] && candidate.numbers[i] === board.numbers[i]),
|
||||
),
|
||||
)
|
||||
for (const candidate of candidates) {
|
||||
candidate.answer.forEach((color, i) => assert.ok(masks[i] & (1 << color)))
|
||||
}
|
||||
const hiddenScrambled = {
|
||||
...board,
|
||||
answer: board.answer.map((c, i) => (revealed[i] ? c : 1 - c)),
|
||||
numbers: board.numbers.map((n, i) => (revealed[i] ? n : 99)),
|
||||
}
|
||||
assert.deepEqual(engine.infer(hiddenScrambled, revealed), masks)
|
||||
}
|
||||
})
|
||||
|
||||
test('diagonal contact does not connect a color', () => {
|
||||
assert.equal(
|
||||
connected({ size: 2, colors: 2, answer: [0, 1, 1, 0], numbers: [1, 1, 1, 1] }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
export const LEVELS = {
|
||||
easy: { size: 10, colors: 4, density: 0.35 },
|
||||
normal: { size: 25, colors: 6, density: 0.15 },
|
||||
hard: { size: 60, colors: 10, density: 0 },
|
||||
} as const
|
||||
export type Difficulty = keyof typeof LEVELS
|
||||
export interface Board {
|
||||
size: number
|
||||
colors: number
|
||||
answer: number[]
|
||||
numbers: number[]
|
||||
}
|
||||
export interface Puzzle extends Board {
|
||||
clues: boolean[]
|
||||
seed: number
|
||||
difficulty: Difficulty
|
||||
}
|
||||
|
||||
const singleton = (mask: number) => mask > 0 && (mask & (mask - 1)) === 0
|
||||
export const colorOf = (mask: number) => 31 - Math.clz32(mask)
|
||||
|
||||
function randomSource(seed: number) {
|
||||
return () => {
|
||||
seed = (seed + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[], random: () => number) {
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1))
|
||||
;[items[i], items[j]] = [items[j], items[i]]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function createEngine(size: number, colors: number) {
|
||||
const count = size * size
|
||||
const all = (1 << colors) - 1
|
||||
const indices = Array.from({ length: count }, (_, i) => i)
|
||||
const around = indices.map((i) => {
|
||||
const neighbors: number[] = []
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const x = (i % size) + dx
|
||||
const y = Math.floor(i / size) + dy
|
||||
if ((dx || dy) && x >= 0 && x < size && y >= 0 && y < size) {
|
||||
neighbors.push(y * size + x)
|
||||
}
|
||||
}
|
||||
}
|
||||
return neighbors
|
||||
})
|
||||
const distance = (a: number, b: number) =>
|
||||
Math.abs((a % size) - (b % size)) + Math.abs(Math.floor(a / size) - Math.floor(b / size))
|
||||
const cross = around.map((neighbors, i) => neighbors.filter((j) => distance(i, j) === 1))
|
||||
|
||||
function grow(random: () => number): Board {
|
||||
const answer: number[] = Array(count).fill(-1)
|
||||
const areas: number[] = Array(colors).fill(0)
|
||||
const seeds: number[] = []
|
||||
for (let color = 0; color < colors; color++) {
|
||||
const candidates = shuffle(
|
||||
indices.filter((i) => answer[i] < 0),
|
||||
random,
|
||||
).slice(0, 80)
|
||||
if (color) {
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
Math.min(...seeds.map((s) => distance(b, s))) -
|
||||
Math.min(...seeds.map((s) => distance(a, s))),
|
||||
)
|
||||
}
|
||||
const seed = candidates[0]
|
||||
answer[seed] = color
|
||||
areas[color]++
|
||||
seeds.push(seed)
|
||||
}
|
||||
const frontier: number[][] = Array.from({ length: colors }, () => [])
|
||||
const queued = Array.from({ length: colors }, () => new Uint8Array(count))
|
||||
function extend(i: number, color: number) {
|
||||
for (const j of cross[i]) {
|
||||
if (answer[j] < 0 && !queued[color][j]) {
|
||||
frontier[color].push(j)
|
||||
queued[color][j] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
seeds.forEach(extend)
|
||||
let left = count - colors
|
||||
while (left) {
|
||||
const available = areas.map((_, c) => c).filter((c) => frontier[c].length)
|
||||
available.sort((a, b) => areas[a] - areas[b])
|
||||
if (!available.length) throw new Error('Incomplete region growth')
|
||||
const color = available[Math.floor(random() * Math.min(2, available.length))]
|
||||
const list = frontier[color]
|
||||
const position = Math.floor(random() * list.length)
|
||||
const i = list[position]
|
||||
list[position] = list[list.length - 1]
|
||||
list.pop()
|
||||
if (answer[i] >= 0) continue
|
||||
answer[i] = color
|
||||
areas[color]++
|
||||
left--
|
||||
extend(i, color)
|
||||
}
|
||||
return {
|
||||
size,
|
||||
colors,
|
||||
answer,
|
||||
numbers: answer.map((color, i) => around[i].filter((j) => answer[j] === color).length),
|
||||
}
|
||||
}
|
||||
|
||||
// Only revealed cells supply numbers. Deduced cells become new clues after a safe paint.
|
||||
function propagate(board: Board, revealed: boolean[], masks: number[]) {
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const i of indices) {
|
||||
if (!revealed[i]) continue
|
||||
const bit = 1 << board.answer[i]
|
||||
const target = board.numbers[i]
|
||||
let sure = 0
|
||||
const optional: number[] = []
|
||||
for (const j of around[i]) {
|
||||
if (masks[j] === bit) sure++
|
||||
else if (masks[j] & bit) optional.push(j)
|
||||
}
|
||||
if (sure > target || sure + optional.length < target) throw new Error('Contradictory clue')
|
||||
if (sure === target || sure + optional.length === target) {
|
||||
for (const j of optional) {
|
||||
masks[j] = sure === target ? masks[j] & ~bit : bit
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectivity(masks: number[]) {
|
||||
let changed = false
|
||||
for (let color = 0; color < colors; color++) {
|
||||
const bit = 1 << color
|
||||
const source = masks.indexOf(bit)
|
||||
if (source < 0) continue
|
||||
// Iterative Tarjan traversal avoids a recursive stack overflow on the 60 × 60 board.
|
||||
const order = new Int32Array(count)
|
||||
const low = new Int32Array(count)
|
||||
const parent = new Int32Array(count).fill(-1)
|
||||
const terminals = new Int32Array(count)
|
||||
const edge = new Uint8Array(count)
|
||||
const stack = [source]
|
||||
let time = 1
|
||||
order[source] = low[source] = terminals[source] = 1
|
||||
while (stack.length) {
|
||||
const u = stack[stack.length - 1]
|
||||
if (edge[u] < cross[u].length) {
|
||||
const v = cross[u][edge[u]++]
|
||||
if (!(masks[v] & bit)) continue
|
||||
if (!order[v]) {
|
||||
parent[v] = u
|
||||
order[v] = low[v] = ++time
|
||||
terminals[v] = masks[v] === bit ? 1 : 0
|
||||
stack.push(v)
|
||||
} else if (v !== parent[u]) low[u] = Math.min(low[u], order[v])
|
||||
} else {
|
||||
stack.pop()
|
||||
const p = parent[u]
|
||||
if (p < 0) continue
|
||||
low[p] = Math.min(low[p], low[u])
|
||||
terminals[p] += terminals[u]
|
||||
// A bridge must be this color if its removal separates two known same-color cells.
|
||||
if (p !== source && low[u] >= order[p] && terminals[u] > 0 && masks[p] !== bit) {
|
||||
masks[p] = bit
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const i of indices) {
|
||||
if (masks[i] & bit && !order[i]) {
|
||||
masks[i] &= ~bit
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (masks.some((mask) => mask === 0)) throw new Error('Empty color domain')
|
||||
return changed
|
||||
}
|
||||
|
||||
function infer(board: Board, revealed: boolean[]) {
|
||||
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
|
||||
do {
|
||||
propagate(board, revealed, masks)
|
||||
} while (connectivity(masks))
|
||||
return masks
|
||||
}
|
||||
|
||||
function solve(board: Board, clues: boolean[]) {
|
||||
const revealed = clues.slice()
|
||||
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
|
||||
while (true) {
|
||||
propagate(board, revealed, masks)
|
||||
const next = indices.filter((i) => !revealed[i] && singleton(masks[i]))
|
||||
if (!next.length) {
|
||||
if (!connectivity(masks)) return revealed
|
||||
continue
|
||||
}
|
||||
for (const i of next) {
|
||||
if (colorOf(masks[i]) !== board.answer[i]) throw new Error('Unsound deduction')
|
||||
revealed[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return { around, cross, grow, infer, solve }
|
||||
}
|
||||
|
||||
export function generate(seed: number, difficulty: Difficulty): Puzzle {
|
||||
const level = LEVELS[difficulty]
|
||||
const random = randomSource(seed)
|
||||
const engine = createEngine(level.size, level.colors)
|
||||
const board = engine.grow(random)
|
||||
const indices = board.answer.map((_, i) => i)
|
||||
const clues: boolean[] = indices.map(() => false)
|
||||
for (let color = 0; color < level.colors; color++) {
|
||||
const cells = shuffle(
|
||||
indices.filter((i) => board.answer[i] === color),
|
||||
random,
|
||||
)
|
||||
cells.sort((a, b) => board.numbers[b] - board.numbers[a])
|
||||
clues[cells[0]] = true
|
||||
}
|
||||
let solved = engine.solve(board, clues)
|
||||
while (solved.some((value) => !value)) {
|
||||
const cells = shuffle(
|
||||
indices.filter((i) => !solved[i]),
|
||||
random,
|
||||
)
|
||||
cells.sort(
|
||||
(a, b) =>
|
||||
engine.around[b].filter((j) => !solved[j]).length -
|
||||
engine.around[a].filter((j) => !solved[j]).length,
|
||||
)
|
||||
clues[cells[0]] = true
|
||||
solved = engine.solve(board, clues)
|
||||
}
|
||||
// Greedy irredundancy under these deduction rules, not a claim of global minimum clue count.
|
||||
for (const i of shuffle(
|
||||
indices.filter((i) => clues[i]),
|
||||
random,
|
||||
)) {
|
||||
if (indices.filter((j) => clues[j] && board.answer[j] === board.answer[i]).length <= 1) continue
|
||||
clues[i] = false
|
||||
if (engine.solve(board, clues).some((value) => !value)) clues[i] = true
|
||||
}
|
||||
const target = Math.ceil(indices.length * level.density)
|
||||
const pools = Array.from({ length: level.colors }, (_, color) =>
|
||||
shuffle(
|
||||
indices.filter((i) => board.answer[i] === color && !clues[i]),
|
||||
random,
|
||||
),
|
||||
)
|
||||
let total = clues.filter(Boolean).length
|
||||
while (total < target) {
|
||||
for (const pool of pools) {
|
||||
if (pool.length && total < target) {
|
||||
clues[pool.pop()!] = true
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...board, clues, seed, difficulty }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type Difficulty, generate } from './engine'
|
||||
|
||||
self.onmessage = (event: MessageEvent<{ seed: number; difficulty: Difficulty }>) => {
|
||||
try {
|
||||
self.postMessage({ puzzle: generate(event.data.seed, event.data.difficulty) })
|
||||
} catch {
|
||||
self.postMessage({ error: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { defineMessages } from '@modrinth/ui'
|
||||
|
||||
export const messages = defineMessages({
|
||||
title: { id: 'app.easteregg.color-mine.title', defaultMessage: 'Starlight Mine: Chromatic Realms' },
|
||||
easy: { id: 'app.easteregg.color-mine.easy', defaultMessage: 'Easy' },
|
||||
normal: { id: 'app.easteregg.color-mine.normal', defaultMessage: 'Normal' },
|
||||
hard: { id: 'app.easteregg.color-mine.hard', defaultMessage: 'Hard' },
|
||||
newGame: { id: 'app.easteregg.color-mine.newGame', defaultMessage: 'New board' },
|
||||
close: { id: 'app.easteregg.color-mine.close', defaultMessage: 'Close' },
|
||||
choose: {
|
||||
id: 'app.easteregg.color-mine.choose',
|
||||
defaultMessage: 'Click a revealed tile to sample its color, then paint a gray tile.',
|
||||
},
|
||||
selected: {
|
||||
id: 'app.easteregg.color-mine.selected',
|
||||
defaultMessage: 'Color {color} selected · Click a colored tile to change your brush.',
|
||||
},
|
||||
rulesTitle: { id: 'app.easteregg.color-mine.rulesTitle', defaultMessage: 'How to play' },
|
||||
rules: {
|
||||
id: 'app.easteregg.color-mine.rules',
|
||||
defaultMessage:
|
||||
'Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing.',
|
||||
},
|
||||
loading: {
|
||||
id: 'app.easteregg.color-mine.loading',
|
||||
defaultMessage: 'Generating regions and checking the deduction path…',
|
||||
},
|
||||
generateError: {
|
||||
id: 'app.easteregg.color-mine.generateError',
|
||||
defaultMessage: 'Could not generate this board. Try again.',
|
||||
},
|
||||
retry: { id: 'app.easteregg.color-mine.retry', defaultMessage: 'Try again' },
|
||||
progress: {
|
||||
id: 'app.easteregg.color-mine.progress',
|
||||
defaultMessage: '{count} / {total} revealed',
|
||||
},
|
||||
best: { id: 'app.easteregg.color-mine.best', defaultMessage: 'Best · {time}' },
|
||||
won: { id: 'app.easteregg.color-mine.won', defaultMessage: 'All colors restored!' },
|
||||
lost: {
|
||||
id: 'app.easteregg.color-mine.lost',
|
||||
defaultMessage:
|
||||
'Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over.',
|
||||
},
|
||||
review: {
|
||||
id: 'app.easteregg.color-mine.review',
|
||||
defaultMessage: 'Unrevealed answers are now shown faded for review.',
|
||||
},
|
||||
leaveTitle: { id: 'app.easteregg.color-mine.leaveTitle', defaultMessage: 'Leave this board?' },
|
||||
leaveText: {
|
||||
id: 'app.easteregg.color-mine.leaveText',
|
||||
defaultMessage: 'Save to continue later, or discard this unfinished board.',
|
||||
},
|
||||
saveLeave: { id: 'app.easteregg.color-mine.saveLeave', defaultMessage: 'Save and leave' },
|
||||
discard: { id: 'app.easteregg.color-mine.discard', defaultMessage: 'Discard' },
|
||||
continue: { id: 'app.easteregg.color-mine.continue', defaultMessage: 'Keep playing' },
|
||||
replaceTitle: {
|
||||
id: 'app.easteregg.color-mine.replaceTitle',
|
||||
defaultMessage: 'Start a new board?',
|
||||
},
|
||||
replaceText: {
|
||||
id: 'app.easteregg.color-mine.replaceText',
|
||||
defaultMessage: 'The current board will be discarded.',
|
||||
},
|
||||
savedTitle: {
|
||||
id: 'app.easteregg.color-mine.savedTitle',
|
||||
defaultMessage: 'An unfinished board is saved',
|
||||
},
|
||||
resume: { id: 'app.easteregg.color-mine.resume', defaultMessage: 'Resume saved game' },
|
||||
storageError: {
|
||||
id: 'app.easteregg.color-mine.storageError',
|
||||
defaultMessage:
|
||||
'Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it.',
|
||||
},
|
||||
recordError: {
|
||||
id: 'app.easteregg.color-mine.recordError',
|
||||
defaultMessage: 'Finished, but the best time could not be saved.',
|
||||
},
|
||||
zoomIn: { id: 'app.easteregg.color-mine.zoomIn', defaultMessage: 'Zoom in' },
|
||||
zoomOut: { id: 'app.easteregg.color-mine.zoomOut', defaultMessage: 'Zoom out' },
|
||||
map: { id: 'app.easteregg.color-mine.map', defaultMessage: 'Board overview. Click to navigate.' },
|
||||
board: {
|
||||
id: 'app.easteregg.color-mine.board',
|
||||
defaultMessage: '{size} by {size} color deduction board',
|
||||
},
|
||||
hidden: {
|
||||
id: 'app.easteregg.color-mine.hidden',
|
||||
defaultMessage: 'Row {row}, column {column}, unrevealed',
|
||||
},
|
||||
revealed: {
|
||||
id: 'app.easteregg.color-mine.revealed',
|
||||
defaultMessage: 'Row {row}, column {column}, color {color}, {number} same-color neighbors',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Semantic colors stay identifiable when the launcher's theme/accent changes. */
|
||||
.color-mine {
|
||||
--mine-color-0: var(--color-red);
|
||||
--mine-color-1: #d7ac37;
|
||||
--mine-color-2: var(--color-green);
|
||||
--mine-color-3: var(--color-blue);
|
||||
--mine-color-4: var(--color-purple);
|
||||
--mine-color-5: var(--color-orange);
|
||||
--mine-color-6: #97ba35;
|
||||
--mine-color-7: #22b6b8;
|
||||
--mine-color-8: var(--color-pink);
|
||||
--mine-color-9: #8999ac;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { generate } from './engine.ts'
|
||||
import {
|
||||
parseSave,
|
||||
readBest,
|
||||
recordBest,
|
||||
SAVE_KEY,
|
||||
type SavedGame,
|
||||
saveGame,
|
||||
validateResume,
|
||||
} from './storage.ts'
|
||||
|
||||
const puzzle = generate(123, 'easy')
|
||||
const saved: SavedGame = {
|
||||
version: 1,
|
||||
seed: puzzle.seed,
|
||||
difficulty: puzzle.difficulty,
|
||||
revealed: puzzle.clues.slice(),
|
||||
selected: -1,
|
||||
elapsed: 4321,
|
||||
zoom: 1,
|
||||
left: 12,
|
||||
top: 34,
|
||||
}
|
||||
function memoryStorage() {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value)
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('save round trip keeps board progress, brush, timer and viewport', () => {
|
||||
const storage = memoryStorage()
|
||||
saveGame(storage, saved)
|
||||
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
|
||||
validateResume(saved, puzzle)
|
||||
assert.equal(parseSave(null), null)
|
||||
})
|
||||
|
||||
test('malformed and incompatible saves are rejected without overwriting data', () => {
|
||||
for (const bad of [
|
||||
null,
|
||||
{},
|
||||
{ ...saved, version: 2 },
|
||||
{ ...saved, difficulty: '__proto__' },
|
||||
{ ...saved, revealed: [true] },
|
||||
{ ...saved, elapsed: -1 },
|
||||
{ ...saved, selected: 15 },
|
||||
{ ...saved, zoom: 5 },
|
||||
{ ...saved, seed: 1.2 },
|
||||
{ ...saved, top: -1 },
|
||||
{ ...saved, revealed: saved.revealed.map(() => true) },
|
||||
]) {
|
||||
assert.throws(() => parseSave(JSON.stringify(bad)))
|
||||
}
|
||||
assert.throws(() => parseSave('{invalid'))
|
||||
assert.throws(() => validateResume({ ...saved, seed: 9 }, puzzle))
|
||||
assert.throws(() =>
|
||||
validateResume({ ...saved, revealed: saved.revealed.map(() => false) }, puzzle),
|
||||
)
|
||||
const storage = memoryStorage()
|
||||
saveGame(storage, saved)
|
||||
assert.throws(() => saveGame(storage, { ...saved, elapsed: -1 }))
|
||||
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
|
||||
})
|
||||
|
||||
test('storage failures are surfaced so the UI can keep the active board', () => {
|
||||
assert.throws(() =>
|
||||
saveGame(
|
||||
{
|
||||
...memoryStorage(),
|
||||
setItem: () => {
|
||||
throw new Error('Quota exceeded')
|
||||
},
|
||||
},
|
||||
saved,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('best times are independent per difficulty and only improve', () => {
|
||||
const storage = memoryStorage()
|
||||
assert.equal(readBest(storage, 'easy'), null)
|
||||
assert.equal(recordBest(storage, 'easy', 1000), 1000)
|
||||
assert.equal(recordBest(storage, 'easy', 2000), 1000)
|
||||
assert.equal(recordBest(storage, 'easy', 500), 500)
|
||||
assert.equal(recordBest(storage, 'hard', 9000), 9000)
|
||||
assert.equal(readBest(storage, 'easy'), 500)
|
||||
assert.throws(() => recordBest(storage, 'easy', -1))
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type Difficulty, LEVELS, type Puzzle } from './engine.ts'
|
||||
|
||||
export const SAVE_KEY = 'starlight.color-mine.save.v1'
|
||||
const BEST_KEY = 'starlight.color-mine.best.v1'
|
||||
export interface SavedGame {
|
||||
version: 1
|
||||
seed: number
|
||||
difficulty: Difficulty
|
||||
revealed: boolean[]
|
||||
selected: number
|
||||
elapsed: number
|
||||
zoom: number
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
type StoragePort = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
||||
const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
export function parseSave(raw: string | null): SavedGame | null {
|
||||
if (raw === null) return null
|
||||
const value = JSON.parse(raw) as Partial<SavedGame> | null
|
||||
if (
|
||||
!value ||
|
||||
value.version !== 1 ||
|
||||
!value.difficulty ||
|
||||
!Object.keys(LEVELS).includes(value.difficulty)
|
||||
) {
|
||||
throw new Error('Invalid saved game')
|
||||
}
|
||||
const level = LEVELS[value.difficulty]
|
||||
if (
|
||||
!finite(value.seed) ||
|
||||
!Number.isInteger(value.seed) ||
|
||||
value.seed < 0 ||
|
||||
value.seed > 0xffffffff ||
|
||||
!Array.isArray(value.revealed) ||
|
||||
value.revealed.length !== level.size ** 2 ||
|
||||
value.revealed.some((v) => typeof v !== 'boolean') ||
|
||||
value.revealed.every(Boolean) ||
|
||||
!finite(value.selected) ||
|
||||
!Number.isInteger(value.selected) ||
|
||||
value.selected < -1 ||
|
||||
value.selected >= level.colors ||
|
||||
!finite(value.elapsed) ||
|
||||
value.elapsed < 0 ||
|
||||
!finite(value.zoom) ||
|
||||
value.zoom < 0.7 ||
|
||||
value.zoom > 1.6 ||
|
||||
!finite(value.left) ||
|
||||
value.left < 0 ||
|
||||
!finite(value.top) ||
|
||||
value.top < 0
|
||||
)
|
||||
throw new Error('Invalid saved game')
|
||||
return value as SavedGame
|
||||
}
|
||||
|
||||
export function validateResume(saved: SavedGame, puzzle: Puzzle) {
|
||||
if (
|
||||
saved.seed !== puzzle.seed ||
|
||||
saved.difficulty !== puzzle.difficulty ||
|
||||
puzzle.clues.some((clue, i) => clue && !saved.revealed[i]) ||
|
||||
(saved.selected >= 0 &&
|
||||
!saved.revealed.some((open, i) => open && puzzle.answer[i] === saved.selected))
|
||||
) {
|
||||
throw new Error('Saved game does not match puzzle')
|
||||
}
|
||||
}
|
||||
|
||||
export function saveGame(storage: StoragePort, saved: SavedGame) {
|
||||
const raw = JSON.stringify(saved)
|
||||
parseSave(raw)
|
||||
storage.setItem(SAVE_KEY, raw)
|
||||
}
|
||||
|
||||
export function readBest(storage: StoragePort, difficulty: Difficulty): number | null {
|
||||
const raw = storage.getItem(BEST_KEY)
|
||||
if (!raw) return null
|
||||
const value: unknown = JSON.parse(raw)
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const time = (value as Record<string, unknown>)[difficulty]
|
||||
return finite(time) && time >= 0 ? time : null
|
||||
}
|
||||
|
||||
export function recordBest(storage: StoragePort, difficulty: Difficulty, elapsed: number) {
|
||||
if (!finite(elapsed) || elapsed < 0) throw new Error('Invalid time')
|
||||
const best = Object.fromEntries(
|
||||
Object.keys(LEVELS).map((key) => [key, readBest(storage, key as Difficulty)]),
|
||||
)
|
||||
best[difficulty] = Math.min(best[difficulty] ?? Infinity, elapsed)
|
||||
storage.setItem(BEST_KEY, JSON.stringify(best))
|
||||
return best[difficulty]
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
||||
|
||||
@@ -91,7 +90,6 @@ defineExpose({
|
||||
instances.value = instanceValues
|
||||
modal.value.show()
|
||||
|
||||
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -102,11 +100,6 @@ async function addServer(instance) {
|
||||
instance.added = true
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
|
||||
|
||||
trackEvent('AddServerToInstance', {
|
||||
server_name: serverName.value,
|
||||
instance_name: instance.name,
|
||||
source: 'AddServerToInstanceModal',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -16,9 +16,10 @@ import { basename, dirname, join } from '@tauri-apps/api/path'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
|
||||
import InstancePlayerSettings from '@/components/instance/InstancePlayerSettings.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
@@ -49,10 +50,6 @@ const installing = computed(() => instance.value.install_stage !== 'installed')
|
||||
|
||||
async function duplicateInstance() {
|
||||
await install_duplicate_instance(instance.value.id).catch(handleError)
|
||||
trackEvent('InstanceDuplicate', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
|
||||
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
||||
@@ -104,7 +101,6 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
||||
async function resetIcon() {
|
||||
icon.value = undefined
|
||||
await edit_icon(instance.value.id, null).catch(handleError)
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
}
|
||||
|
||||
async function setIcon() {
|
||||
@@ -116,7 +112,6 @@ async function setIcon() {
|
||||
icon.value = picked.path
|
||||
try {
|
||||
await edit_icon(instance.value.id, picked.path)
|
||||
trackEvent('InstanceSetIcon')
|
||||
} catch (error) {
|
||||
icon.value = previousIcon
|
||||
handleError(error)
|
||||
@@ -236,7 +231,7 @@ async function setGameDirMode(mode: GameDirMode) {
|
||||
}
|
||||
|
||||
const editInstanceObject = computed(() => ({
|
||||
name: title.value.trim().substring(0, 32) ?? 'Instance',
|
||||
name: title.value.trim(),
|
||||
}))
|
||||
|
||||
watch(
|
||||
@@ -253,10 +248,6 @@ async function removeInstance() {
|
||||
removing.value = true
|
||||
const path = instance.value.id
|
||||
|
||||
trackEvent('InstanceRemove', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
|
||||
await router.push({ path: '/' })
|
||||
await remove(path).catch(handleError)
|
||||
@@ -390,6 +381,8 @@ const messages = defineMessages({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
|
||||
<InstancePlayerSettings :instance-id="instance.id" />
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="deleteConfirmModal"
|
||||
:symlink-target="instance.symlink_target"
|
||||
@@ -448,7 +441,6 @@ const messages = defineMessages({
|
||||
id="instance-name"
|
||||
v-model="title"
|
||||
autocomplete="off"
|
||||
:maxlength="80"
|
||||
wrapper-class="flex-grow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,6 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
|
||||
import {
|
||||
@@ -333,10 +332,6 @@ provideInstallationSettings({
|
||||
afterSave: async () => {
|
||||
debug('afterSave: installing', { instanceId: instance.value.id })
|
||||
await install_existing_instance(instance.value.id, false).catch(handleError)
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('afterSave: done')
|
||||
},
|
||||
|
||||
@@ -345,40 +340,27 @@ provideInstallationSettings({
|
||||
repairing.value = true
|
||||
await install_existing_instance(instance.value.id, true).catch(handleError)
|
||||
repairing.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('repair: done')
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
let shouldTrack = false
|
||||
try {
|
||||
if (isImportedModpack.value) {
|
||||
shouldTrack = await installLocalModpackFromPicker()
|
||||
await installLocalModpackFromPicker()
|
||||
} else if (isCurseForgeLinkedModpack.value) {
|
||||
const fileId = Number(instance.value.link?.version_id)
|
||||
if (!Number.isFinite(fileId)) {
|
||||
throw new Error('Invalid CurseForge file ID')
|
||||
}
|
||||
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
|
||||
shouldTrack = true
|
||||
} else {
|
||||
await update_repair_modrinth(instance.value.id).catch(handleError)
|
||||
shouldTrack = true
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
if (shouldTrack) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
debug('reinstallModpack: done')
|
||||
},
|
||||
|
||||
@@ -386,13 +368,7 @@ provideInstallationSettings({
|
||||
debug('swapModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
try {
|
||||
const installed = await installLocalModpackFromPicker()
|
||||
if (installed) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
await installLocalModpackFromPicker()
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -67,6 +67,7 @@ withDefaults(
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
symlinkTarget: null,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -127,7 +127,7 @@ interface IconBackground {
|
||||
style: Record<string, string>
|
||||
}
|
||||
|
||||
function background(
|
||||
function makeBackground(
|
||||
id: string,
|
||||
name: MessageDescriptor,
|
||||
top: string,
|
||||
@@ -171,16 +171,16 @@ const backgrounds = [
|
||||
backgroundSize: '16px 16px',
|
||||
},
|
||||
} satisfies IconBackground,
|
||||
background('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
|
||||
background('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
|
||||
background('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
|
||||
background('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
|
||||
background('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
|
||||
background('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
|
||||
background('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
|
||||
background('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
|
||||
background('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
|
||||
background('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
|
||||
makeBackground('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
|
||||
makeBackground('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
|
||||
makeBackground('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
|
||||
makeBackground('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
|
||||
makeBackground('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
|
||||
makeBackground('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
|
||||
makeBackground('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
|
||||
makeBackground('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
|
||||
makeBackground('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
|
||||
makeBackground('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
|
||||
]
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [privacy: PrivacySettings]
|
||||
}>()
|
||||
|
||||
const CONSENT_VERSION = 1
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const telemetry = ref(true)
|
||||
const discordRpc = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.privacy-consent.title',
|
||||
defaultMessage: 'Privacy & security',
|
||||
},
|
||||
intro: {
|
||||
id: 'app.privacy-consent.intro',
|
||||
defaultMessage:
|
||||
'Choose what Starlight may send or display. Nothing is sent until you confirm these choices.',
|
||||
},
|
||||
telemetry: {
|
||||
id: 'app.privacy-consent.telemetry',
|
||||
defaultMessage: 'Allow anonymous telemetry',
|
||||
},
|
||||
telemetryDescription: {
|
||||
id: 'app.privacy-consent.telemetry-description',
|
||||
defaultMessage:
|
||||
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
|
||||
},
|
||||
discordRpc: {
|
||||
id: 'app.privacy-consent.discord-rpc',
|
||||
defaultMessage: 'Discord Rich Presence',
|
||||
},
|
||||
discordRpcDescription: {
|
||||
id: 'app.privacy-consent.discord-rpc-description',
|
||||
defaultMessage:
|
||||
'Shows your current launcher or game activity in Discord when Discord is running.',
|
||||
},
|
||||
privacyPolicy: {
|
||||
id: 'app.privacy-consent.privacy-policy',
|
||||
defaultMessage: 'Read the privacy policy',
|
||||
},
|
||||
continue: {
|
||||
id: 'app.privacy-consent.continue',
|
||||
defaultMessage: 'Save and continue',
|
||||
},
|
||||
})
|
||||
|
||||
function show(current: PrivacySettings) {
|
||||
telemetry.value = true
|
||||
discordRpc.value = current.discord_rpc
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const privacy = await savePrivacySettings({
|
||||
telemetry: telemetry.value,
|
||||
discord_rpc: discordRpc.value,
|
||||
consent_version: CONSENT_VERSION,
|
||||
})
|
||||
modal.value?.hide()
|
||||
emit('saved', privacy)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
|
||||
<p class="m-0 leading-relaxed text-primary">
|
||||
{{ formatMessage(messages.intro) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-5">
|
||||
<div class="min-w-0">
|
||||
<label for="consent-telemetry" class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.telemetry) }}
|
||||
</label>
|
||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.telemetryDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-5">
|
||||
<div class="min-w-0">
|
||||
<label for="consent-discord-rpc" class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.discordRpc) }}
|
||||
</label>
|
||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.discordRpcDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<ButtonStyled type="transparent">
|
||||
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.privacyPolicy) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="saving" @click="save">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
{{ formatMessage(messages.continue) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -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;
|
||||
|
||||
@@ -110,7 +110,8 @@ const { formatMessage } = useVIntl()
|
||||
color: var(--color-contrast);
|
||||
font-size: 4.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
// Keep descenders inside the line box clipped by the wordmark reveal animation.
|
||||
line-height: normal;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
|
||||
@@ -41,24 +41,6 @@ export const onboardingMessages = defineMessages({
|
||||
defaultMessage: 'Your last next launcher.',
|
||||
},
|
||||
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
|
||||
homeWidgetsTitle: {
|
||||
id: 'app.onboarding.home-widgets.title',
|
||||
defaultMessage: 'Your Home, your layout',
|
||||
},
|
||||
homeWidgetsDescription: {
|
||||
id: 'app.onboarding.home-widgets.description',
|
||||
defaultMessage:
|
||||
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
|
||||
},
|
||||
homeCustomizeTitle: {
|
||||
id: 'app.onboarding.home-customize.title',
|
||||
defaultMessage: 'Arrange it your way',
|
||||
},
|
||||
homeCustomizeDescription: {
|
||||
id: 'app.onboarding.home-customize.description',
|
||||
defaultMessage:
|
||||
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
|
||||
},
|
||||
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
|
||||
discoverDescription: {
|
||||
id: 'app.onboarding.discover.description',
|
||||
@@ -90,12 +72,12 @@ export const onboardingMessages = defineMessages({
|
||||
},
|
||||
homeLayoutTitle: {
|
||||
id: 'app.onboarding.home-layout.title',
|
||||
defaultMessage: 'Change the amount of detail',
|
||||
defaultMessage: 'Switch your home',
|
||||
},
|
||||
homeLayoutDescription: {
|
||||
id: 'app.onboarding.home-layout.description',
|
||||
defaultMessage:
|
||||
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
|
||||
'Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher.',
|
||||
},
|
||||
continueArea: {
|
||||
id: 'app.onboarding.action.continue-area',
|
||||
@@ -105,7 +87,7 @@ export const onboardingMessages = defineMessages({
|
||||
skinsDescription: {
|
||||
id: 'app.onboarding.skins.description',
|
||||
defaultMessage:
|
||||
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
|
||||
'Choose a skin site player or Minecraft account, then preview and apply the skins available to that profile.',
|
||||
},
|
||||
clickSkins: {
|
||||
id: 'app.onboarding.action.click-skins',
|
||||
@@ -114,16 +96,8 @@ export const onboardingMessages = defineMessages({
|
||||
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
|
||||
skinsPageDescription: {
|
||||
id: 'app.onboarding.skins-page.description',
|
||||
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
|
||||
},
|
||||
accountTitle: {
|
||||
id: 'app.onboarding.account.title',
|
||||
defaultMessage: 'Accounts, on your schedule',
|
||||
},
|
||||
accountDescription: {
|
||||
id: 'app.onboarding.account.description',
|
||||
defaultMessage:
|
||||
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
|
||||
'Select a profile, preview its available skins, and apply changes when that account supports skin management.',
|
||||
},
|
||||
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
|
||||
downloadsDescription: {
|
||||
@@ -160,35 +134,6 @@ export const onboardingMessages = defineMessages({
|
||||
defaultMessage:
|
||||
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
|
||||
},
|
||||
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
|
||||
languageDescription: {
|
||||
id: 'app.onboarding.language.description',
|
||||
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
|
||||
},
|
||||
translationTitle: {
|
||||
id: 'app.onboarding.translation.title',
|
||||
defaultMessage: 'Translation, the Starlight way',
|
||||
},
|
||||
translationDescription: {
|
||||
id: 'app.onboarding.translation.description',
|
||||
defaultMessage:
|
||||
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
|
||||
},
|
||||
aiTitle: {
|
||||
id: 'app.onboarding.ai.title',
|
||||
defaultMessage: 'Bring your own AI provider',
|
||||
},
|
||||
aiDescription: {
|
||||
id: 'app.onboarding.ai.description',
|
||||
defaultMessage:
|
||||
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
|
||||
},
|
||||
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
|
||||
javaDescription: {
|
||||
id: 'app.onboarding.java.description',
|
||||
defaultMessage:
|
||||
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
|
||||
},
|
||||
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
|
||||
defaultsDescription: {
|
||||
id: 'app.onboarding.defaults.description',
|
||||
@@ -204,20 +149,6 @@ export const onboardingMessages = defineMessages({
|
||||
defaultMessage:
|
||||
'Choose how content downloads and installs, from download sources to safety checks.',
|
||||
},
|
||||
privacyTitle: {
|
||||
id: 'app.onboarding.privacy.title',
|
||||
defaultMessage: 'Your data, your call',
|
||||
},
|
||||
privacyDescription: {
|
||||
id: 'app.onboarding.privacy.description',
|
||||
defaultMessage:
|
||||
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
|
||||
},
|
||||
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
|
||||
updatesDescription: {
|
||||
id: 'app.onboarding.updates.description',
|
||||
defaultMessage: 'Choose when Starlight checks for updates and whether it installs them for you.',
|
||||
},
|
||||
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
|
||||
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
|
||||
libraryDescription: {
|
||||
@@ -236,7 +167,7 @@ export const onboardingMessages = defineMessages({
|
||||
libraryPageDescription: {
|
||||
id: 'app.onboarding.library-page.description',
|
||||
defaultMessage:
|
||||
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
|
||||
'Switch between all instances, modpacks, and custom setups, then open any instance to manage it.',
|
||||
},
|
||||
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
|
||||
createDescription: {
|
||||
@@ -248,6 +179,15 @@ export const onboardingMessages = defineMessages({
|
||||
defaultMessage: 'Click Create new instance to continue',
|
||||
},
|
||||
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
|
||||
instanceModeTitle: {
|
||||
id: 'app.onboarding.instance-mode.title',
|
||||
defaultMessage: 'Choose your instance type',
|
||||
},
|
||||
instanceModeDescription: {
|
||||
id: 'app.onboarding.instance-mode.description',
|
||||
defaultMessage:
|
||||
'StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player.',
|
||||
},
|
||||
creationDescription: {
|
||||
id: 'app.onboarding.creation.description',
|
||||
defaultMessage:
|
||||
@@ -302,7 +242,7 @@ export const onboardingMessages = defineMessages({
|
||||
instanceActionsDescription: {
|
||||
id: 'app.onboarding.instance-actions.description',
|
||||
defaultMessage:
|
||||
'Launch, stop, repair, configure, export, or open the instance from its header.',
|
||||
'Launch or configure this instance here. You can launch another window while it is running; use the top bar to stop an individual window. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
|
||||
},
|
||||
instanceTabsTitle: {
|
||||
id: 'app.onboarding.instance-tabs.title',
|
||||
@@ -312,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',
|
||||
@@ -319,7 +295,7 @@ export const onboardingMessages = defineMessages({
|
||||
labDescription: {
|
||||
id: 'app.onboarding.lab.description',
|
||||
defaultMessage:
|
||||
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
|
||||
'The Lab keeps Minecraft creation, world, and maintenance tools inside the launcher.',
|
||||
},
|
||||
clickLab: {
|
||||
id: 'app.onboarding.action.click-lab',
|
||||
@@ -332,66 +308,52 @@ export const onboardingMessages = defineMessages({
|
||||
labToolsDescription: {
|
||||
id: 'app.onboarding.lab-tools.description',
|
||||
defaultMessage:
|
||||
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
|
||||
},
|
||||
openGradientText: {
|
||||
id: 'app.onboarding.action.open-gradient-text',
|
||||
defaultMessage: 'Open Gradient text generator to continue',
|
||||
},
|
||||
labEditorTitle: {
|
||||
id: 'app.onboarding.lab-editor.title',
|
||||
defaultMessage: 'Build and copy in one place',
|
||||
},
|
||||
labEditorDescription: {
|
||||
id: 'app.onboarding.lab-editor.description',
|
||||
defaultMessage:
|
||||
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
|
||||
},
|
||||
labSeedMapTitle: {
|
||||
id: 'app.onboarding.lab-seed-map.title',
|
||||
defaultMessage: 'Find a world before you load it',
|
||||
},
|
||||
labSeedMapDescription: {
|
||||
id: 'app.onboarding.lab-seed-map.description',
|
||||
defaultMessage:
|
||||
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
|
||||
},
|
||||
returnToLab: {
|
||||
id: 'app.onboarding.action.return-lab',
|
||||
defaultMessage: 'Click Lab to continue',
|
||||
},
|
||||
openSeedMap: {
|
||||
id: 'app.onboarding.action.open-seed-map',
|
||||
defaultMessage: 'Open Seed map to continue',
|
||||
},
|
||||
openSchematicWorkshop: {
|
||||
id: 'app.onboarding.action.open-schematic-workshop',
|
||||
defaultMessage: 'Open Schematic workshop to continue',
|
||||
},
|
||||
openRecipeGenerator: {
|
||||
id: 'app.onboarding.action.open-recipe-generator',
|
||||
defaultMessage: 'Open Recipe generator to continue',
|
||||
},
|
||||
labRecipeGeneratorTitle: {
|
||||
id: 'app.onboarding.lab-recipe-generator.title',
|
||||
defaultMessage: 'Craft data pack recipes',
|
||||
},
|
||||
labRecipeGeneratorDescription: {
|
||||
id: 'app.onboarding.lab-recipe-generator.description',
|
||||
defaultMessage:
|
||||
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
|
||||
},
|
||||
labSchematicTitle: {
|
||||
id: 'app.onboarding.lab-schematic.title',
|
||||
defaultMessage: 'Inspect a build before placing it',
|
||||
},
|
||||
labSchematicDescription: {
|
||||
id: 'app.onboarding.lab-schematic.description',
|
||||
defaultMessage:
|
||||
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
|
||||
'Create and edit skins, generate formatted text and recipes, explore seeds, inspect schematics, and translate mods locally.',
|
||||
},
|
||||
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 = (
|
||||
@@ -447,32 +409,33 @@ const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescri
|
||||
|
||||
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
main: [
|
||||
inspect(
|
||||
'home-intro',
|
||||
'home-widget-grid',
|
||||
onboardingMessages.homeIntroTitle,
|
||||
onboardingMessages.homeIntroDescription,
|
||||
),
|
||||
inspect(
|
||||
'account-login',
|
||||
'sidebar-accounts',
|
||||
onboardingMessages.accountLoginTitle,
|
||||
onboardingMessages.accountLoginDescription,
|
||||
),
|
||||
inspect(
|
||||
'announcement',
|
||||
'home-announcement',
|
||||
onboardingMessages.announcementTitle,
|
||||
onboardingMessages.announcementDescription,
|
||||
),
|
||||
step(
|
||||
'welcome',
|
||||
'manual',
|
||||
'create-starlight',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.welcomeTitle,
|
||||
onboardingMessages.welcomeDescription,
|
||||
onboardingMessages.start,
|
||||
onboardingMessages.createStarlightTitle,
|
||||
onboardingMessages.createStarlightDescription,
|
||||
onboardingMessages.clickCreate,
|
||||
),
|
||||
),
|
||||
inspect(
|
||||
'home-widget-grid',
|
||||
'home-widget-grid',
|
||||
onboardingMessages.homeWidgetsTitle,
|
||||
onboardingMessages.homeWidgetsDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-widget-customize',
|
||||
'home-widget-customize',
|
||||
onboardingMessages.homeCustomizeTitle,
|
||||
onboardingMessages.homeCustomizeDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-layout-switch',
|
||||
'home-layout-switch',
|
||||
onboardingMessages.homeLayoutTitle,
|
||||
onboardingMessages.homeLayoutDescription,
|
||||
control('create-instance', '/create'),
|
||||
),
|
||||
step(
|
||||
'discover-navigation',
|
||||
@@ -482,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',
|
||||
@@ -490,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',
|
||||
@@ -523,14 +470,20 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.skinsPageDescription,
|
||||
),
|
||||
step(
|
||||
'account',
|
||||
'inspect',
|
||||
'library-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.accountTitle,
|
||||
onboardingMessages.accountDescription,
|
||||
onboardingMessages.continueArea,
|
||||
onboardingMessages.libraryTitle,
|
||||
onboardingMessages.libraryDescription,
|
||||
onboardingMessages.clickLibrary,
|
||||
),
|
||||
control('account-entry'),
|
||||
control('nav-library', '/library'),
|
||||
),
|
||||
inspect(
|
||||
'library-content',
|
||||
'library-content',
|
||||
onboardingMessages.libraryPageTitle,
|
||||
onboardingMessages.libraryPageDescription,
|
||||
),
|
||||
step(
|
||||
'lab-navigation',
|
||||
@@ -548,90 +501,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.labToolsTitle,
|
||||
onboardingMessages.labToolsDescription,
|
||||
),
|
||||
step(
|
||||
'lab-gradient-text-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labEditorTitle,
|
||||
onboardingMessages.labEditorDescription,
|
||||
onboardingMessages.openGradientText,
|
||||
),
|
||||
control('lab-gradient-text-card', '/lab/gradient-text'),
|
||||
),
|
||||
inspect(
|
||||
'lab-gradient-text-editor',
|
||||
'lab-gradient-text-editor',
|
||||
onboardingMessages.labEditorTitle,
|
||||
onboardingMessages.labEditorDescription,
|
||||
),
|
||||
step(
|
||||
'lab-return-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
onboardingMessages.returnToLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
step(
|
||||
'lab-seed-map-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
onboardingMessages.openSeedMap,
|
||||
),
|
||||
control('lab-seed-map-card', '/lab/seed-map'),
|
||||
),
|
||||
inspect(
|
||||
'lab-seed-map-workspace',
|
||||
'seed-map-workspace',
|
||||
onboardingMessages.labSeedMapTitle,
|
||||
onboardingMessages.labSeedMapDescription,
|
||||
),
|
||||
step(
|
||||
'lab-return-schematic-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
onboardingMessages.returnToLab,
|
||||
),
|
||||
control('nav-lab', '/lab'),
|
||||
),
|
||||
step(
|
||||
'lab-schematic-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
onboardingMessages.openSchematicWorkshop,
|
||||
),
|
||||
control('lab-schematic-preview-card', '/lab/schematic-preview'),
|
||||
),
|
||||
inspect(
|
||||
'lab-schematic-workspace',
|
||||
'schematic-preview-workspace',
|
||||
onboardingMessages.labSchematicTitle,
|
||||
onboardingMessages.labSchematicDescription,
|
||||
),
|
||||
step(
|
||||
'lab-recipe-generator-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.labRecipeGeneratorTitle,
|
||||
onboardingMessages.labRecipeGeneratorDescription,
|
||||
onboardingMessages.openRecipeGenerator,
|
||||
),
|
||||
control('lab-recipe-generator-card', '/lab/recipe-generator'),
|
||||
),
|
||||
inspect(
|
||||
'lab-recipe-generator-workspace',
|
||||
'recipe-generator-workspace',
|
||||
onboardingMessages.labRecipeGeneratorTitle,
|
||||
onboardingMessages.labRecipeGeneratorDescription,
|
||||
),
|
||||
step(
|
||||
'downloads-navigation',
|
||||
'navigate',
|
||||
@@ -650,7 +519,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
),
|
||||
step(
|
||||
'settings-navigation',
|
||||
'activate',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.settingsTitle,
|
||||
onboardingMessages.settingsDescription,
|
||||
@@ -658,88 +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'),
|
||||
),
|
||||
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-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: [
|
||||
@@ -749,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>
|
||||