Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e61c5f0945 | ||
|
|
4a1d48a7a9 | ||
|
|
d513c6c756 | ||
|
|
9b85f3bcdf | ||
|
|
4ff6ebbe6c | ||
|
|
c3c6760818 | ||
|
|
36f9dbb47a | ||
|
|
715f4954cf | ||
|
|
ffdf7ff745 | ||
|
|
a921cfdb7a | ||
|
|
1007b7b69e | ||
|
|
c28ec83060 | ||
|
|
7701aef048 | ||
|
|
7420560784 | ||
|
|
97c6ece304 | ||
|
|
73afbfeb56 | ||
|
|
8a0afa1bf7 | ||
|
|
e33ef5f252 | ||
|
|
87aa698148 | ||
|
|
7a383b98b9 | ||
|
|
8cfd8ee660 | ||
|
|
bb35950340 | ||
|
|
c67f980c4a | ||
|
|
80607c1963 | ||
|
|
ad83117ebf | ||
|
|
4b5edb9a99 | ||
|
|
e753801e0c | ||
|
|
aed00aab13 | ||
|
|
8d7e491126 | ||
|
|
fb39dabe43 | ||
|
|
bc50ed5f65 | ||
|
|
fe9ae94a5d | ||
|
|
871a161327 | ||
|
|
797ec2ec3c | ||
|
|
d999624702 | ||
|
|
20b3a42156 | ||
|
|
237963bb35 | ||
|
|
ba667921d1 | ||
|
|
56dba8ccc2 | ||
|
|
82d0184a85 | ||
|
|
5f9522b5cf | ||
|
|
75d2109be8 | ||
|
|
7e611c7678 | ||
|
|
0bd341371a | ||
|
|
32a43f2a22 | ||
|
|
2909aa49ca | ||
|
|
816830eea3 | ||
|
|
087752312b | ||
|
|
bcd7133251 | ||
|
|
a85b8eed3f | ||
|
|
52e055b20f | ||
|
|
5181370293 | ||
|
|
5d54cfa0dc | ||
|
|
5eb64cea3e | ||
|
|
6670e3184e | ||
|
|
12928edbcb | ||
|
|
c94034f4e2 | ||
|
|
8eb12d5d24 | ||
|
|
6a92ab0c2c | ||
|
|
d74211e827 | ||
|
|
49628a0ebc | ||
|
|
5b3177d27a | ||
|
|
a6043fc51f | ||
|
|
f08d977aff | ||
|
|
bee0c14b31 | ||
|
|
f39a602be3 | ||
|
|
3e07267e10 | ||
|
|
79fcf41316 | ||
|
|
8f36ed5f8f | ||
|
|
10b1d7002a | ||
|
|
2f9956f507 | ||
|
|
311d603168 | ||
|
|
efe20653c7 | ||
|
|
906ead762d | ||
|
|
e568efa2a1 | ||
|
|
c8816418ea | ||
|
|
12540a5fd7 | ||
|
|
fd7794c807 | ||
|
|
b18081d962 | ||
|
|
e79e0d024b | ||
|
|
d5556271f9 | ||
|
|
1b341a8e07 | ||
|
|
bac2a55bb7 | ||
|
|
2f01c29b7d | ||
|
|
04b1d70823 | ||
|
|
1c02e2fc50 | ||
|
|
d0f85edcfa | ||
|
|
39c0951ecb | ||
|
|
46d07163cd | ||
|
|
d192fb805b | ||
|
|
b3b0b85691 | ||
|
|
760a1adbe4 | ||
|
|
9e07617173 | ||
|
|
369cd7aa91 | ||
|
|
9cb02f3abf | ||
|
|
7c24822a1a | ||
|
|
3326c6d973 | ||
|
|
248c7bb217 | ||
|
|
65df8b90b8 | ||
|
|
377ebfe746 | ||
|
|
14b2943dbe | ||
|
|
5b16a774f9 | ||
|
|
b0ec93f8ab | ||
|
|
2070428080 | ||
|
|
9582c1a6fd | ||
|
|
4897c2da94 | ||
|
|
457254f210 |
@@ -14,12 +14,14 @@ jobs:
|
||||
comment:
|
||||
name: Post changelog comment
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Post or update changelog comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const marker = '<!-- changelog -->';
|
||||
const mergedMarker = '<!-- changelog-merged -->';
|
||||
|
||||
@@ -20,8 +20,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
|
||||
- uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
|
||||
with:
|
||||
tool: tombi
|
||||
tool: tombi@1.4.1
|
||||
- run: tombi lint
|
||||
- run: tombi fmt --check
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- 'packages/ui/**/*'
|
||||
- 'packages/utils/**/*'
|
||||
- 'packages/assets/**/*'
|
||||
- 'packages/moderation/**/*'
|
||||
- '**/wrangler.jsonc'
|
||||
- '**/pnpm-*.yaml'
|
||||
- '.github/workflows/frontend-deploy.yml'
|
||||
@@ -124,8 +125,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ./apps/frontend
|
||||
run: pnpm build
|
||||
run: pnpm web:build
|
||||
env:
|
||||
CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }}
|
||||
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Frontend Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
paths:
|
||||
- 'apps/frontend/**/*'
|
||||
- 'packages/ui/**/*'
|
||||
- 'packages/utils/**/*'
|
||||
- 'packages/assets/**/*'
|
||||
- 'packages/moderation/**/*'
|
||||
- 'packages/api-client/**/*'
|
||||
- 'packages/blog/**/*'
|
||||
- '**/pnpm-*.yaml'
|
||||
- .github/workflows/frontend-docker.yml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build frontend
|
||||
runs-on: namespace-profile-modrinth-frontend
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Configure environment
|
||||
id: meta
|
||||
env:
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
if [ "$REF" = "refs/heads/main" ]; then
|
||||
echo "env=staging" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "env=production" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up caches
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
cache: |
|
||||
pnpm
|
||||
|
||||
- name: Inject build variables
|
||||
working-directory: ./apps/frontend
|
||||
run: |
|
||||
if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then
|
||||
echo "Injecting staging variables from wrangler.jsonc..."
|
||||
jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
|
||||
else
|
||||
echo "Injecting production variables from wrangler.jsonc..."
|
||||
jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./apps/frontend
|
||||
run: pnpm install
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm web:build
|
||||
env:
|
||||
NITRO_PRESET: node-server
|
||||
BUILD_ENV: ${{ steps.meta.outputs.env }}
|
||||
CF_PAGES_BRANCH: ${{ github.ref_name }}
|
||||
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
|
||||
|
||||
- name: Stage Docker context
|
||||
run: |
|
||||
mkdir -p apps/frontend/docker-stage
|
||||
cp -r apps/frontend/.output apps/frontend/docker-stage/.output
|
||||
|
||||
- name: Upload Docker context
|
||||
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
|
||||
with:
|
||||
name: frontend-docker-context
|
||||
retention-days: 1
|
||||
path: apps/frontend/docker-stage
|
||||
|
||||
docker-build:
|
||||
needs: [build]
|
||||
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
|
||||
with:
|
||||
image-name: frontend
|
||||
dockerfile-path: apps/frontend/Dockerfile
|
||||
artifacts-name: frontend-docker-context
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'packages/ui/**/*'
|
||||
- 'packages/utils/**/*'
|
||||
- 'packages/assets/**/*'
|
||||
- 'packages/moderation/**/*'
|
||||
- '**/wrangler.jsonc'
|
||||
- '**/pnpm-*.yaml'
|
||||
- '.github/workflows/frontend-preview.yml'
|
||||
@@ -69,10 +70,9 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build Storybook
|
||||
working-directory: ./packages/ui
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
run: pnpm run build-storybook
|
||||
run: pnpm build-storybook
|
||||
|
||||
- name: Configure short SHA
|
||||
id: meta
|
||||
@@ -117,6 +117,8 @@ jobs:
|
||||
&& needs.deploy.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [metadata, deploy, deploy-storybook]
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Download deployment URLs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -157,7 +159,7 @@ jobs:
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: fc
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
token: ${{ github.token }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: Frontend previews
|
||||
|
||||
@@ -165,7 +167,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
token: ${{ github.token }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
body: |
|
||||
|
||||
@@ -8,6 +8,10 @@ on:
|
||||
concurrency:
|
||||
group: i18n-management
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pull_translations:
|
||||
name: 'Pull translations from Crowdin'
|
||||
@@ -38,23 +42,17 @@ jobs:
|
||||
flight_failure "CROWDIN_PERSONAL_TOKEN secret is not defined (required to push)"
|
||||
fi
|
||||
|
||||
if [ "$CROWDIN_GH_TOKEN_DEFINED" != true ]; then
|
||||
flight_failure "CROWDIN_GH_TOKEN secret is not defined (required to make pull requests)"
|
||||
fi
|
||||
|
||||
if [ "$PREFLIGHT_CHECK_RESULT" = false ]; then
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
CROWDIN_PROJECT_ID_DEFINED: ${{ vars.CROWDIN_PROJECT_ID != '' }}
|
||||
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
|
||||
CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -67,12 +65,6 @@ jobs:
|
||||
- name: Install script dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Configure Git author
|
||||
id: git-author
|
||||
uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
# # Because --all flag of Crowdin CLI is currently broken we need to create a fake source file
|
||||
# # so that the CLI won't omit translations for it. See https://github.com/crowdin/crowdin-cli/issues/724
|
||||
# - name: Write fake sources
|
||||
@@ -116,7 +108,4 @@ jobs:
|
||||
body-path: .github/templates/crowdin-pr.md
|
||||
commit-message: 'New translations from Crowdin (${{ steps.branch-name.outputs.branch_name }})'
|
||||
branch: crowdin-pull/${{ steps.branch-name.outputs.branch_name }}
|
||||
author: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
|
||||
committer: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
|
||||
labels: sync
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
@@ -55,6 +55,7 @@ Thumbs.db
|
||||
|
||||
# frontend generated files
|
||||
apps/frontend/src/generated
|
||||
/packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.ts
|
||||
|
||||
.turbo
|
||||
target
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/xredis/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/component-derive/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
||||
@@ -300,6 +300,22 @@ dependencies = [
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aditude"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"aditude",
|
||||
"arc-swap",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"eyre",
|
||||
"reqwest 0.12.24",
|
||||
"rust_decimal",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -2223,6 +2239,16 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "component-derive"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.31"
|
||||
@@ -2666,10 +2692,10 @@ dependencies = [
|
||||
"futures",
|
||||
"indexmap 2.11.4",
|
||||
"itertools 0.14.0",
|
||||
"quick-xml 0.38.3",
|
||||
"reqwest 0.12.24",
|
||||
"rust-s3",
|
||||
"serde",
|
||||
"serde-xml-rs",
|
||||
"serde_json",
|
||||
"sha1_smol",
|
||||
"thiserror 2.0.17",
|
||||
@@ -5463,6 +5489,7 @@ dependencies = [
|
||||
"actix-web",
|
||||
"actix-web-prom",
|
||||
"actix-ws",
|
||||
"aditude",
|
||||
"arc-swap",
|
||||
"argon2",
|
||||
"ariadne",
|
||||
@@ -5480,6 +5507,7 @@ dependencies = [
|
||||
"clickhouse",
|
||||
"color-eyre",
|
||||
"color-thief",
|
||||
"component-derive",
|
||||
"const_format",
|
||||
"dashmap",
|
||||
"derive_more 2.1.1",
|
||||
@@ -5557,16 +5585,6 @@ dependencies = [
|
||||
"zxcvbn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "labrinth-derive"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "language-tags"
|
||||
version = "0.3.2"
|
||||
@@ -9432,18 +9450,6 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-xml-rs"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53630160a98edebde0123eb4dfd0fce6adff091b2305db3154a9e920206eb510"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
|
||||
@@ -5,10 +5,11 @@ members = [
|
||||
"apps/app-playground",
|
||||
"apps/daedalus_client",
|
||||
"apps/labrinth",
|
||||
"packages/aditude",
|
||||
"packages/app-lib",
|
||||
"packages/ariadne",
|
||||
"packages/component-derive",
|
||||
"packages/daedalus",
|
||||
"packages/labrinth-derive",
|
||||
"packages/modrinth-content-management",
|
||||
"packages/modrinth-log",
|
||||
"packages/modrinth-maxmind",
|
||||
@@ -42,9 +43,7 @@ async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
] }
|
||||
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"
|
||||
aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
|
||||
@@ -55,19 +54,19 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
|
||||
base64 = "0.22.1"
|
||||
bitflags = "2.9.4"
|
||||
bon = "3.9.3"
|
||||
aditude = { path = "packages/aditude" }
|
||||
bytemuck = "1.24.0"
|
||||
bytes = "1.10.1"
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.42"
|
||||
chumsky = "0.9.3"
|
||||
cidre = { version = "0.15.0", default-features = false, features = [
|
||||
"macos_15_0"
|
||||
] }
|
||||
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
|
||||
clap = "4.5.48"
|
||||
clickhouse = "0.14.0"
|
||||
color-eyre = "0.6.5"
|
||||
color-thief = "0.2.2"
|
||||
component-derive = { path = "packages/component-derive" }
|
||||
const_format = "0.2.34"
|
||||
core-foundation = "0.10.1"
|
||||
core-graphics = "0.24.0"
|
||||
@@ -162,10 +161,7 @@ redis = "1.4.1"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.24", default-features = false }
|
||||
rgb = "0.8.52"
|
||||
rust_decimal = { version = "1.39.0", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str"
|
||||
] }
|
||||
rust_decimal = { version = "1.39.0", features = ["serde-with-float", "serde-with-str"] }
|
||||
rust_iso3166 = "0.1.14"
|
||||
rust-s3 = { version = "0.37.0", default-features = false, features = [
|
||||
"fail-on-err",
|
||||
@@ -191,7 +187,6 @@ serde_cbor = "0.11.2"
|
||||
serde_ini = "0.2.0"
|
||||
serde_json = "1.0.145"
|
||||
serde_with = "3.15.0"
|
||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||
sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
sha2 = "0.10.9"
|
||||
|
||||
@@ -6,13 +6,16 @@ extend-exclude = [
|
||||
"packages/utils/",
|
||||
"packages/ui/",
|
||||
"packages/blog/",
|
||||
# contains licenses like `CC-BY-ND-4.0`
|
||||
"packages/moderation/src/data/stages/license.ts",
|
||||
"packages/moderation/src/utils.ts",
|
||||
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
|
||||
"apps/labrinth/src/queue/payouts/mod.rs",
|
||||
# contains domain names with deliberate typos
|
||||
"apps/labrinth/assets/disposable_email_blocklist.txt"
|
||||
]
|
||||
|
||||
[default]
|
||||
extend-ignore-re = [
|
||||
# Ignore lines that end with `// typos:ignore`
|
||||
"(?Rm)^.*(#|//)\\s*typos:ignore$",
|
||||
# Ignore the line after `// typos:ignore`:
|
||||
"(#|//)\\s*typos:ignore\\n.*",
|
||||
# Ignore blocks between `// typos:off` and `// typos:on`
|
||||
"(?s)(#|//)\\s*typos:off.*?\\n\\s*(#|//)\\s*typos:on",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/vue": "^0.5.0",
|
||||
"@modrinth/api-client": "workspace:^",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
@@ -37,7 +38,6 @@
|
||||
"intl-messageformat": "^10.7.7",
|
||||
"ofetch": "^1.3.4",
|
||||
"overlayscrollbars": "^2.15.1",
|
||||
"pinia": "^3.0.0",
|
||||
"posthog-js": "^1.158.2",
|
||||
"three": "^0.172.0",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
|
||||
@@ -13,11 +13,10 @@ import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CompassIcon,
|
||||
HomeIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
NewspaperIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
RightArrowIcon,
|
||||
@@ -57,7 +56,7 @@ import {
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
@@ -74,6 +73,7 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import HostingUpdateRequired from '@/components/ui/HostingUpdateRequired.vue'
|
||||
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
|
||||
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
||||
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
|
||||
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
|
||||
import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
@@ -82,6 +82,9 @@ import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyIn
|
||||
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import NewIconEditorNotification from '@/components/ui/new-icon-editor-notification/index.vue'
|
||||
import { shouldShowNewIconEditorNotification } from '@/components/ui/new-icon-editor-notification/show-notification'
|
||||
import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
|
||||
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
@@ -91,6 +94,9 @@ import SurveyPopup from '@/components/ui/SurveyPopup.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { useError } from '@/composables/use-error.js'
|
||||
import { useTheme } from '@/composables/use-theme.ts'
|
||||
import { config } from '@/config'
|
||||
import {
|
||||
hide_ads_window,
|
||||
@@ -144,8 +150,7 @@ import { setupProviders } from '@/providers/setup'
|
||||
import { setupAppEventsProvider } from '@/providers/setup/app-events'
|
||||
import { setupAuthProvider } from '@/providers/setup/auth'
|
||||
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
|
||||
import { useError } from '@/store/error.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { setupAppUserPreferencesProvider } from '@/providers/setup/user-preferences.ts'
|
||||
import { appMessages } from '@/utils/app-messages'
|
||||
|
||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
@@ -154,7 +159,8 @@ import { AppNotificationManager } from './providers/app-notifications'
|
||||
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
|
||||
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const appSettings = useAppSettings()
|
||||
const appTheme = useTheme()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider()
|
||||
@@ -198,7 +204,7 @@ const credentials = ref()
|
||||
let credentialsRefreshId = 0
|
||||
const sidebarToggled = ref(true)
|
||||
watch(
|
||||
() => themeStore.toggleSidebar,
|
||||
() => appSettings.toggleSidebar,
|
||||
(toggleSidebar) => {
|
||||
sidebarToggled.value = !toggleSidebar
|
||||
},
|
||||
@@ -218,7 +224,7 @@ const hostingUpdateRequired = computed(
|
||||
appUpdateState.updatesEnabled.value,
|
||||
)
|
||||
const prideFundraiserEnabled = computed(
|
||||
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
|
||||
() => appSettings.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
|
||||
)
|
||||
const hostingIntercomIdentityKey = computed(() => {
|
||||
const rawServerId = route.params.id
|
||||
@@ -248,7 +254,7 @@ useAppEvent(
|
||||
'warning',
|
||||
(event) =>
|
||||
addNotification({
|
||||
title: 'Warning',
|
||||
title: formatMessage(messages.warning),
|
||||
text: event.message,
|
||||
type: 'warning',
|
||||
}),
|
||||
@@ -321,30 +327,79 @@ providePageContext({
|
||||
intercomBubble: hostingIntercom.intercomBubble,
|
||||
featureFlags: {
|
||||
serverRamAsBytesAlwaysOn: computed(() =>
|
||||
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
|
||||
appSettings.getFeatureFlag('server_ram_as_bytes_always_on'),
|
||||
),
|
||||
},
|
||||
openExternalUrl: (url) => void openUrl(url),
|
||||
})
|
||||
provideModalBehavior({
|
||||
noblur: computed(() => !themeStore.advancedRendering),
|
||||
noblur: computed(() => !appTheme.advancedRendering),
|
||||
onShow: () => take_ads_window_hold(),
|
||||
onHide: () => release_ads_window_hold(),
|
||||
})
|
||||
|
||||
const creationIconEditorModal = ref(null)
|
||||
const creationGeneratedIcon = ref(null)
|
||||
const creationIconTarget = ref('creation-flow')
|
||||
|
||||
const {
|
||||
installationModal,
|
||||
unknownPackWarningModal,
|
||||
fetchExistingInstanceNames,
|
||||
handleCreate,
|
||||
handleBrowseModpacks,
|
||||
searchModpacks,
|
||||
getProjectVersions,
|
||||
searchProjects,
|
||||
getLoaderManifest,
|
||||
setModpackAlreadyInstalledModal,
|
||||
handleModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance,
|
||||
} = setupProviders(tauriApiClient, notificationManager, popupNotificationManager)
|
||||
onboardingChecklist,
|
||||
} = setupProviders(
|
||||
tauriApiClient,
|
||||
notificationManager,
|
||||
popupNotificationManager,
|
||||
appEvents,
|
||||
(iconPath) =>
|
||||
creationGeneratedIcon.value?.path === iconPath ? creationGeneratedIcon.value.config : null,
|
||||
)
|
||||
const { hasLoggedIntoMinecraft, hasLoggedIntoModrinth, showChecklist } = onboardingChecklist
|
||||
const showFriendsList = computed(() => !showChecklist.value || hasLoggedIntoModrinth.value)
|
||||
|
||||
async function randomizeCreationIcon() {
|
||||
const generated = await creationIconEditorModal.value?.randomizeAndSave()
|
||||
if (!generated) return null
|
||||
|
||||
creationGeneratedIcon.value = { path: generated.iconPath, config: generated.config }
|
||||
return {
|
||||
path: generated.iconPath,
|
||||
previewUrl: convertFileSrc(generated.iconPath),
|
||||
}
|
||||
}
|
||||
|
||||
function customizeCreationIcon() {
|
||||
creationIconTarget.value = 'creation-flow'
|
||||
creationIconEditorModal.value?.show()
|
||||
}
|
||||
|
||||
function customizeContentInstallIcon() {
|
||||
creationIconTarget.value = 'content-install'
|
||||
creationIconEditorModal.value?.show()
|
||||
}
|
||||
|
||||
function onCreationIconSaved(iconPath, config) {
|
||||
creationGeneratedIcon.value = { path: iconPath, config }
|
||||
if (creationIconTarget.value === 'content-install') {
|
||||
modInstallModal.value?.setIcon(iconPath, convertFileSrc(iconPath))
|
||||
return
|
||||
}
|
||||
|
||||
const context = installationModal.value?.ctx
|
||||
if (!context) return
|
||||
|
||||
context.instanceIcon.value = null
|
||||
context.instanceIconUrl.value = convertFileSrc(iconPath)
|
||||
context.instanceIconPath.value = iconPath
|
||||
}
|
||||
|
||||
const news = ref([])
|
||||
const displayedServerInviteNotifications = new Set()
|
||||
@@ -360,7 +415,6 @@ window.addEventListener('online', () => {
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
const showOnboarding = ref(false)
|
||||
const nativeDecorations = ref(false)
|
||||
|
||||
const os = ref('')
|
||||
@@ -425,6 +479,15 @@ const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const messages = defineMessages({
|
||||
warning: { id: 'app.notification.warning', defaultMessage: 'Warning' },
|
||||
moreOptions: { id: 'app.navigation.more-options', defaultMessage: 'More options' },
|
||||
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
|
||||
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
|
||||
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
|
||||
updateDownloadMissingVersion: {
|
||||
id: 'app.update.download-error.missing-version',
|
||||
defaultMessage: 'Failed to download update: no version available',
|
||||
},
|
||||
updateInstalledToastTitle: {
|
||||
id: 'app.update.complete-toast.title',
|
||||
defaultMessage: 'Version {version} was successfully installed!',
|
||||
@@ -467,10 +530,6 @@ const messages = defineMessages({
|
||||
id: 'app.nav.home',
|
||||
defaultMessage: 'Home',
|
||||
},
|
||||
library: {
|
||||
id: 'app.nav.library',
|
||||
defaultMessage: 'Library',
|
||||
},
|
||||
modrinthHosting: {
|
||||
id: 'app.nav.modrinth-hosting',
|
||||
defaultMessage: 'Modrinth Hosting',
|
||||
@@ -530,6 +589,7 @@ function handleAdsConsentRequired(required) {
|
||||
}
|
||||
|
||||
const notification = addPopupNotification({
|
||||
contentType: 'standard',
|
||||
title: formatMessage(messages.adsConsentTitle),
|
||||
text: formatMessage(messages.adsConsentBody),
|
||||
type: 'info',
|
||||
@@ -562,17 +622,26 @@ function handleAdsConsentRequired(required) {
|
||||
}
|
||||
|
||||
async function setupApp() {
|
||||
await onboardingChecklist.initialize()
|
||||
|
||||
if (shouldShowNewIconEditorNotification(showChecklist.value)) {
|
||||
addPopupNotification({
|
||||
contentType: 'custom',
|
||||
component: NewIconEditorNotification,
|
||||
autoCloseMs: null,
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
native_decorations,
|
||||
theme,
|
||||
locale,
|
||||
telemetry,
|
||||
collapsed_navigation,
|
||||
hide_nametag_skins_page,
|
||||
advanced_rendering,
|
||||
onboarded,
|
||||
default_page,
|
||||
toggle_sidebar,
|
||||
sync_theme_across_devices,
|
||||
sync_behavior_across_devices,
|
||||
developer_mode,
|
||||
feature_flags,
|
||||
pending_update_toast_for_version,
|
||||
@@ -583,26 +652,21 @@ async function setupApp() {
|
||||
i18n.global.locale.value = locale
|
||||
}
|
||||
|
||||
if (default_page === 'Library') {
|
||||
await router.push('/library')
|
||||
}
|
||||
|
||||
os.value = await getOS()
|
||||
const dev = await isDev()
|
||||
isDevEnvironment.value = dev
|
||||
const version = await getVersion()
|
||||
showOnboarding.value = !onboarded
|
||||
|
||||
nativeDecorations.value = native_decorations
|
||||
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
|
||||
|
||||
themeStore.setThemeState(theme)
|
||||
themeStore.collapsedNavigation = collapsed_navigation
|
||||
themeStore.advancedRendering = advanced_rendering
|
||||
themeStore.hideNametagSkinsPage = hide_nametag_skins_page
|
||||
themeStore.toggleSidebar = toggle_sidebar
|
||||
themeStore.devMode = developer_mode
|
||||
themeStore.featureFlags = feature_flags
|
||||
appTheme.preferred = theme
|
||||
appTheme.advancedRendering = advanced_rendering
|
||||
appTheme.syncAcrossDevices = sync_theme_across_devices
|
||||
appSettings.syncBehaviorAcrossDevices = sync_behavior_across_devices
|
||||
appSettings.hideNametagSkinsPage = hide_nametag_skins_page
|
||||
appSettings.toggleSidebar = toggle_sidebar
|
||||
appSettings.devMode = developer_mode
|
||||
Object.assign(appSettings.featureFlags, feature_flags)
|
||||
stateInitialized.value = true
|
||||
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
@@ -614,7 +678,7 @@ async function setupApp() {
|
||||
if (telemetry) {
|
||||
initAnalytics()
|
||||
if (dev) debugAnalytics()
|
||||
trackEvent('Launched', { version, dev, onboarded })
|
||||
trackEvent('Launched', { version, dev })
|
||||
}
|
||||
|
||||
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
|
||||
@@ -818,6 +882,7 @@ const {
|
||||
projectInfo: contentInstallProjectInfo,
|
||||
handleInstallToInstance,
|
||||
handleCreateAndInstall,
|
||||
prepareNewInstance,
|
||||
handleNavigate: handleContentInstallNavigate,
|
||||
handleCancel: handleContentInstallCancel,
|
||||
setContentInstallModal,
|
||||
@@ -837,6 +902,35 @@ const {
|
||||
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
|
||||
} = contentInstall
|
||||
|
||||
async function prepareCreationProjectInstall(projectId, projectType) {
|
||||
if (projectType === 'modpack') {
|
||||
await contentInstall.install(
|
||||
projectId,
|
||||
null,
|
||||
null,
|
||||
'CreationModalProject',
|
||||
undefined,
|
||||
(instanceId) => void router.push(`/instance/${encodeURIComponent(instanceId)}`),
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
await prepareNewInstance(projectId)
|
||||
const info = contentInstallProjectInfo.value
|
||||
if (!info) throw new Error(`Project information is unavailable: '${projectId}'`)
|
||||
|
||||
return {
|
||||
projectId,
|
||||
title: info.title,
|
||||
iconUrl: info.iconUrl,
|
||||
link: info.link,
|
||||
owner: info.owner,
|
||||
compatibleLoaders: [...contentInstallLoaders.value],
|
||||
gameVersions: [...contentInstallGameVersions.value],
|
||||
releaseGameVersions: new Set(contentInstallReleaseGameVersions.value),
|
||||
}
|
||||
}
|
||||
|
||||
const serverInstall = createServerInstall({
|
||||
router,
|
||||
handleError,
|
||||
@@ -870,7 +964,7 @@ watch(incompatibilityWarningModal, (modal) => {
|
||||
}
|
||||
})
|
||||
|
||||
setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
|
||||
const authProvider = setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
|
||||
if (options?.showModal === false) {
|
||||
await signIn(flow)
|
||||
} else {
|
||||
@@ -878,6 +972,80 @@ setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
|
||||
}
|
||||
})
|
||||
|
||||
const userPreferences = setupAppUserPreferencesProvider(authProvider, notificationManager)
|
||||
let userPreferencesSync = Promise.resolve()
|
||||
|
||||
watch(
|
||||
[userPreferences.preferences, stateInitialized],
|
||||
([preferences, initialized]) => {
|
||||
if (!preferences || !initialized) return
|
||||
|
||||
userPreferencesSync = userPreferencesSync
|
||||
.then(async () => {
|
||||
const settings = await getSettings()
|
||||
const selectedTheme = preferences.appearance.auto ? 'system' : preferences.appearance.theme
|
||||
const locale = preferences.localization.locale
|
||||
const behavior = preferences.behavior
|
||||
let settingsChanged = false
|
||||
|
||||
if (appTheme.syncAcrossDevices && appTheme.preferred !== selectedTheme) {
|
||||
appTheme.preferred = selectedTheme
|
||||
}
|
||||
if (i18n.global.locale.value !== locale) {
|
||||
i18n.global.locale.value = locale
|
||||
}
|
||||
|
||||
if (appTheme.syncAcrossDevices && settings.theme !== selectedTheme) {
|
||||
settings.theme = selectedTheme
|
||||
settingsChanged = true
|
||||
}
|
||||
if (settings.locale !== locale) {
|
||||
settings.locale = locale
|
||||
settingsChanged = true
|
||||
}
|
||||
|
||||
if (behavior && appSettings.syncBehaviorAcrossDevices) {
|
||||
const behaviorFeatureFlags = {
|
||||
worlds_in_home: behavior.show_jump_in,
|
||||
compact_instance_cards: behavior.compact_instance_cards,
|
||||
show_instance_play_time: behavior.show_play_time,
|
||||
skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks,
|
||||
skip_non_essential_warnings: behavior.skip_non_essential_warnings,
|
||||
}
|
||||
|
||||
appSettings.toggleSidebar = behavior.hide_right_sidebar
|
||||
appSettings.hideNametagSkinsPage = behavior.hide_nametag
|
||||
Object.assign(appSettings.featureFlags, behaviorFeatureFlags)
|
||||
|
||||
if (settings.hide_on_process_start !== behavior.minimize_app) {
|
||||
settings.hide_on_process_start = behavior.minimize_app
|
||||
settingsChanged = true
|
||||
}
|
||||
if (settings.toggle_sidebar !== behavior.hide_right_sidebar) {
|
||||
settings.toggle_sidebar = behavior.hide_right_sidebar
|
||||
settingsChanged = true
|
||||
}
|
||||
if (settings.hide_nametag_skins_page !== behavior.hide_nametag) {
|
||||
settings.hide_nametag_skins_page = behavior.hide_nametag
|
||||
settingsChanged = true
|
||||
}
|
||||
for (const [flag, value] of Object.entries(behaviorFeatureFlags)) {
|
||||
if (settings.feature_flags[flag] !== value) {
|
||||
settings.feature_flags[flag] = value
|
||||
settingsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsChanged) {
|
||||
await setSettings(settings)
|
||||
}
|
||||
})
|
||||
.catch(handleError)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function validateSession(sessionToken) {
|
||||
try {
|
||||
const response = await tauriFetch(`${config.labrinthBaseUrl}/v2/user`, {
|
||||
@@ -1090,17 +1258,16 @@ async function handleLiveNotification(notification) {
|
||||
if (generation !== liveNotificationGeneration) return
|
||||
|
||||
const popupNotification = addPopupNotification({
|
||||
contentType: 'toast',
|
||||
title: serverName,
|
||||
type: 'server-invite',
|
||||
actorName: invitedBy?.username ?? null,
|
||||
actorAvatarUrl: invitedBy?.avatar_url ?? null,
|
||||
entityName: serverName,
|
||||
autoCloseMs: null,
|
||||
toast: {
|
||||
type: 'server-invite',
|
||||
actorName: invitedBy?.username ?? null,
|
||||
actorAvatarUrl: invitedBy?.avatar_url ?? null,
|
||||
entityName: serverName,
|
||||
onAccept: () => acceptServerInviteNotification(notification),
|
||||
onDecline: () => declineServerInviteNotification(notification),
|
||||
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
|
||||
},
|
||||
onAccept: () => acceptServerInviteNotification(notification),
|
||||
onDecline: () => declineServerInviteNotification(notification),
|
||||
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
|
||||
})
|
||||
serverInvitePopupNotificationIds.add(popupNotification.id)
|
||||
}
|
||||
@@ -1277,6 +1444,7 @@ function showDelayedUpdatePopup() {
|
||||
|
||||
if (metered.value && !finishedDownloading.value) {
|
||||
addPopupNotification({
|
||||
contentType: 'standard',
|
||||
title: formatMessage(updatePopupMessages.updateAvailable),
|
||||
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
|
||||
type: 'info',
|
||||
@@ -1298,6 +1466,7 @@ function showDelayedUpdatePopup() {
|
||||
})
|
||||
} else if (finishedDownloading.value) {
|
||||
addPopupNotification({
|
||||
contentType: 'standard',
|
||||
title: formatMessage(updatePopupMessages.downloadComplete),
|
||||
text: formatMessage(updatePopupMessages.downloadedBody, {
|
||||
version: update.version,
|
||||
@@ -1396,6 +1565,7 @@ async function checkLinuxUpdates() {
|
||||
const nextPopupTime = getNextAppUpdatePopupTime(latestVersion)
|
||||
if (nextPopupTime !== null && Date.now() >= nextPopupTime) {
|
||||
addPopupNotification({
|
||||
contentType: 'standard',
|
||||
title: formatMessage(updatePopupMessages.updateAvailable),
|
||||
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
|
||||
type: 'info',
|
||||
@@ -1415,7 +1585,7 @@ async function downloadAvailableUpdate() {
|
||||
|
||||
async function downloadUpdate(versionToDownload) {
|
||||
if (!versionToDownload) {
|
||||
handleError(`Failed to download update: no version available`)
|
||||
handleError(formatMessage(messages.updateDownloadMissingVersion))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1432,9 +1602,8 @@ async function downloadUpdate(versionToDownload) {
|
||||
.then(() => {
|
||||
downloading.value = false
|
||||
finishedDownloading.value = true
|
||||
unlistenUpdateDownload?.().then(() => {
|
||||
unlistenUpdateDownload = null
|
||||
})
|
||||
unlistenUpdateDownload?.()
|
||||
unlistenUpdateDownload = null
|
||||
console.log('Finished downloading!')
|
||||
markAppUpdateActionable(versionToDownload.version, 'downloaded')
|
||||
scheduleDelayedUpdatePopup()
|
||||
@@ -1551,7 +1720,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<div
|
||||
v-if="stateInitialized"
|
||||
class="app-grid-layout relative"
|
||||
:class="{ 'disable-advanced-rendering': !themeStore.advancedRendering }"
|
||||
:class="{ 'disable-advanced-rendering': !appTheme.advancedRendering }"
|
||||
>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
@@ -1579,18 +1748,34 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
type="instance"
|
||||
show-snapshot-toggle
|
||||
:fetch-existing-instance-names="fetchExistingInstanceNames"
|
||||
:search-modpacks="searchModpacks"
|
||||
:get-project-versions="getProjectVersions"
|
||||
:search-projects="searchProjects"
|
||||
:prepare-project-install="prepareCreationProjectInstall"
|
||||
:create-project-install="handleCreateAndInstall"
|
||||
:get-loader-manifest="getLoaderManifest"
|
||||
:randomize-instance-icon="randomizeCreationIcon"
|
||||
:customize-instance-icon="customizeCreationIcon"
|
||||
@create="handleCreate"
|
||||
@browse-modpacks="handleBrowseModpacks"
|
||||
/>
|
||||
<IconEditorModal
|
||||
ref="creationIconEditorModal"
|
||||
:config="creationGeneratedIcon?.config"
|
||||
@saved="onCreationIconSaved"
|
||||
/>
|
||||
<UnknownPackWarningModal ref="unknownPackWarningModal" />
|
||||
<div
|
||||
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.25rem] w-[--left-bar-width]"
|
||||
>
|
||||
<NavButton v-tooltip.right="formatMessage(messages.home)" to="/">
|
||||
<HomeIcon />
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.home)"
|
||||
to="/"
|
||||
:is-primary="(route) => route.path === '/'"
|
||||
:is-subpage="
|
||||
() =>
|
||||
(route.path.startsWith('/browse') || route.path.startsWith('/project')) && route.query.i
|
||||
"
|
||||
>
|
||||
<PlayIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
|
||||
@@ -1605,19 +1790,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
|
||||
<ShirtIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.library)"
|
||||
to="/library"
|
||||
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
|
||||
:is-subpage="
|
||||
() =>
|
||||
route.path.startsWith('/instance') ||
|
||||
((route.path.startsWith('/browse') || route.path.startsWith('/project')) &&
|
||||
route.query.i)
|
||||
"
|
||||
>
|
||||
<LibraryIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.modrinthHosting)"
|
||||
to="/hosting/manage"
|
||||
@@ -1652,7 +1824,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
v-tooltip.right="formatMessage(messages.modrinthAccount)"
|
||||
type="quiet"
|
||||
size="xl"
|
||||
label="More options"
|
||||
:label="formatMessage(messages.moreOptions)"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
@@ -1707,7 +1879,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
|
||||
<IconButton
|
||||
type="outlined"
|
||||
label="Go back"
|
||||
:label="formatMessage(messages.goBack)"
|
||||
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
|
||||
:disabled="!canNavigateBack"
|
||||
@click="router.back()"
|
||||
@@ -1719,7 +1891,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="outlined"
|
||||
label="Go forward"
|
||||
:label="formatMessage(messages.goForward)"
|
||||
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
|
||||
:disabled="!canNavigateForward"
|
||||
@click="router.forward()"
|
||||
@@ -1734,9 +1906,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</div>
|
||||
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
|
||||
<IconButton
|
||||
v-if="!forceSidebar && themeStore.toggleSidebar"
|
||||
v-if="!forceSidebar && appSettings.toggleSidebar"
|
||||
:type="sidebarToggled ? 'base' : 'quiet'"
|
||||
label="Next image"
|
||||
:label="formatMessage(messages.nextImage)"
|
||||
class="mr-3 transition-transform"
|
||||
:class="{ 'rotate-180': !sidebarToggled }"
|
||||
@click="sidebarToggled = !sidebarToggled"
|
||||
@@ -1757,7 +1929,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
class="app-contents"
|
||||
:class="{
|
||||
'sidebar-enabled': sidebarVisible,
|
||||
'disable-advanced-rendering': !themeStore.advancedRendering,
|
||||
'disable-advanced-rendering': !appTheme.advancedRendering,
|
||||
}"
|
||||
>
|
||||
<div class="app-viewport flex-grow router-view">
|
||||
@@ -1773,7 +1945,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<LoadingBar position="absolute" />
|
||||
</div>
|
||||
<div
|
||||
v-if="themeStore.featureFlags.page_path"
|
||||
v-if="appSettings.featureFlags.page_path"
|
||||
class="absolute bottom-0 left-0 m-2 bg-tooltip-bg text-tooltip-text font-semibold rounded-full px-2 py-1 text-xs z-50"
|
||||
>
|
||||
{{ route.fullPath }}
|
||||
@@ -1808,7 +1980,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<RouterView v-else v-slot="{ Component }">
|
||||
<template v-if="Component">
|
||||
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
|
||||
<component :is="Component"></component>
|
||||
<KeepAlive include="LibraryPage">
|
||||
<component :is="Component"></component>
|
||||
</KeepAlive>
|
||||
</Suspense>
|
||||
</template>
|
||||
</RouterView>
|
||||
@@ -1823,9 +1997,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
:class="{ 'pb-12': !hasPlus }"
|
||||
data-overlayscrollbars-initialize
|
||||
>
|
||||
<OnboardingChecklist
|
||||
@create-instance="installationModal?.show()"
|
||||
@login-minecraft="accounts?.login()"
|
||||
@login-modrinth="signIn"
|
||||
/>
|
||||
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
|
||||
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<div
|
||||
v-show="hasLoggedIntoMinecraft"
|
||||
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) }}
|
||||
</h3>
|
||||
@@ -1833,7 +2015,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<AccountsCard ref="accounts" />
|
||||
</suspense>
|
||||
</div>
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<div
|
||||
v-show="showFriendsList"
|
||||
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
>
|
||||
<suspense>
|
||||
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
|
||||
</suspense>
|
||||
@@ -1897,6 +2082,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
:preferred-game-version="contentInstallPreferredGameVersion"
|
||||
:release-game-versions="contentInstallReleaseGameVersions"
|
||||
:project-info="contentInstallProjectInfo"
|
||||
:randomize-icon="randomizeCreationIcon"
|
||||
:customize-icon="customizeContentInstallIcon"
|
||||
@install="handleInstallToInstance"
|
||||
@create-and-install="handleCreateAndInstall"
|
||||
@navigate="handleContentInstallNavigate"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="30.8333" height="20.8333" viewBox="0 0 30.8333 20.8333" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M1.6228 20.8333C2.51907 20.8333 3.24562 20.1158 3.24562 19.2308L3.24563 17.5253C3.24562 17.0803 3.2459 16.016 3.7136 15.2292C4.00182 14.7442 4.37085 14.339 4.74993 14.1326C5.36078 13.8001 6.0611 13.6111 6.80555 13.6111C7.05077 13.6111 7.3144 13.6219 7.6104 13.6356L7.7201 13.6408C7.97828 13.6529 8.25927 13.6661 8.5395 13.6719C9.17298 13.6851 9.91222 13.6651 10.6258 13.4739C12.0158 13.1014 13.1015 12.0158 13.4738 10.6258C13.665 9.91222 13.685 9.17298 13.672 8.5395C13.6662 8.25937 13.6528 7.9782 13.6408 7.7201L13.6357 7.6104C13.6218 7.3144 13.6112 7.05077 13.6112 6.80555C13.6112 6.02607 13.8182 5.29497 14.1805 4.66428C14.3917 4.29663 14.7595 3.96015 15.2675 3.66463C16.0377 3.21645 16.96 3.2123 17.5237 3.20975C18.0872 3.20722 18.6818 3.20527 19.1787 3.20527C22.2357 3.20527 23.347 3.2282 24.1857 3.5193C25.6395 4.02402 26.7495 5.07765 27.2667 6.36838C27.3963 6.69185 27.4878 7.1167 27.5368 7.85255C27.5868 8.60265 27.5877 9.56012 27.5877 10.9399V19.2308C27.5877 20.1158 28.3143 20.8333 29.2105 20.8333C30.1068 20.8333 30.8333 20.1158 30.8333 19.2308V10.8854C30.8333 9.5729 30.8333 8.50977 30.7755 7.6419C30.7158 6.74778 30.5898 5.95072 30.2847 5.18902C29.4037 2.99072 27.5568 1.29222 25.262 0.495583C23.8303 -0.0014166 22.1092 -0.000816752 19.5178 8.32478e-05L17.939 0.000200073L17.9167 0L17.8582 0.000249942C13.6775 0.00308328 11.0862 0.0528001 8.93633 0.79915C5.23583 2.08378 2.2801 4.81432 0.87485 8.32102C0.40655 9.48963 0.199033 10.7449 0.0986334 12.2503C-1.66334e-05 13.7293 0 15.5563 0 17.8857V19.2308C0 20.1158 0.72655 20.8333 1.6228 20.8333Z" fill="#B0BAC5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 228 KiB |
@@ -1,360 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
DropdownSelect,
|
||||
formatLoader,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { remove } from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
const instanceOptions = ref(null)
|
||||
const instanceComponents = ref(null)
|
||||
|
||||
const currentDeleteInstance = ref(null)
|
||||
const confirmModal = ref(null)
|
||||
|
||||
async function deleteInstance() {
|
||||
if (currentDeleteInstance.value) {
|
||||
instanceComponents.value = instanceComponents.value.filter(
|
||||
(x) => x.instance.id !== currentDeleteInstance.value,
|
||||
)
|
||||
await remove(currentDeleteInstance.value).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleRightClick = (event, instanceId) => {
|
||||
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
|
||||
const baseOptions = [
|
||||
...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open' },
|
||||
{ name: 'copy' },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
name: 'delete',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
instanceOptions.value.showMenu(
|
||||
event,
|
||||
item,
|
||||
item.playing
|
||||
? [
|
||||
{
|
||||
name: 'stop',
|
||||
color: 'danger',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
: [
|
||||
...(item.instance.quarantined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
]),
|
||||
...baseOptions,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
args.item.play(null, 'InstanceGridContextMenu')
|
||||
break
|
||||
case 'stop':
|
||||
args.item.stop(null, 'InstanceGridContextMenu')
|
||||
break
|
||||
case 'add_content':
|
||||
await args.item.addContent()
|
||||
break
|
||||
case 'edit':
|
||||
await args.item.seeInstance()
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.instance.install_stage == 'installed')
|
||||
await duplicateInstance(args.item.instance.id)
|
||||
break
|
||||
case 'open':
|
||||
await args.item.openFolder()
|
||||
break
|
||||
case 'copy':
|
||||
await navigator.clipboard.writeText(args.item.instance.id)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item.instance.id
|
||||
confirmModal.value.show()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const state = useStorage(
|
||||
`${props.label}-grid-display-state`,
|
||||
{
|
||||
group: 'Group',
|
||||
sortBy: 'Name',
|
||||
collapsedGroups: [],
|
||||
},
|
||||
localStorage,
|
||||
{ mergeDefaults: true },
|
||||
)
|
||||
|
||||
const search = ref('')
|
||||
const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? []))
|
||||
|
||||
const getSectionKey = (sectionName) => `${state.value.group}:${sectionName}`
|
||||
|
||||
const isSectionCollapsed = (sectionName) => {
|
||||
return collapsedSectionKeys.value.has(getSectionKey(sectionName))
|
||||
}
|
||||
|
||||
const setSectionCollapsed = (sectionName, collapsed) => {
|
||||
const sectionKey = getSectionKey(sectionName)
|
||||
const collapsedSections = new Set(state.value.collapsedGroups ?? [])
|
||||
|
||||
if (collapsed) {
|
||||
collapsedSections.add(sectionKey)
|
||||
} else {
|
||||
collapsedSections.delete(sectionKey)
|
||||
}
|
||||
|
||||
state.value.collapsedGroups = [...collapsedSections]
|
||||
}
|
||||
|
||||
const filteredResults = computed(() => {
|
||||
const { group = 'Group', sortBy = 'Name' } = state.value
|
||||
|
||||
const instances = props.instances.filter((instance) => {
|
||||
return instance.name.toLowerCase().includes(search.value.toLowerCase())
|
||||
})
|
||||
|
||||
if (sortBy === 'Name') {
|
||||
instances.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
if (sortBy === 'Game version') {
|
||||
instances.sort((a, b) => {
|
||||
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
|
||||
})
|
||||
}
|
||||
|
||||
if (sortBy === 'Last played') {
|
||||
instances.sort((a, b) => {
|
||||
return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))
|
||||
})
|
||||
}
|
||||
|
||||
if (sortBy === 'Date created') {
|
||||
instances.sort((a, b) => {
|
||||
return dayjs(b.date_created).diff(dayjs(a.date_created))
|
||||
})
|
||||
}
|
||||
|
||||
if (sortBy === 'Date modified') {
|
||||
instances.sort((a, b) => {
|
||||
return dayjs(b.date_modified).diff(dayjs(a.date_modified))
|
||||
})
|
||||
}
|
||||
|
||||
const instanceMap = new Map()
|
||||
|
||||
if (group === 'Loader') {
|
||||
instances.forEach((instance) => {
|
||||
const loader = formatLoader(formatMessage, instance.loader)
|
||||
if (!instanceMap.has(loader)) {
|
||||
instanceMap.set(loader, [])
|
||||
}
|
||||
|
||||
instanceMap.get(loader).push(instance)
|
||||
})
|
||||
} else if (group === 'Game version') {
|
||||
instances.forEach((instance) => {
|
||||
if (!instanceMap.has(instance.game_version)) {
|
||||
instanceMap.set(instance.game_version, [])
|
||||
}
|
||||
|
||||
instanceMap.get(instance.game_version).push(instance)
|
||||
})
|
||||
} else if (group === 'Group') {
|
||||
instances.forEach((instance) => {
|
||||
if (instance.groups.length === 0) {
|
||||
instance.groups.push('None')
|
||||
}
|
||||
|
||||
for (const category of instance.groups) {
|
||||
if (!instanceMap.has(category)) {
|
||||
instanceMap.set(category, [])
|
||||
}
|
||||
|
||||
instanceMap.get(category).push(instance)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return instanceMap.set('None', instances)
|
||||
}
|
||||
|
||||
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
|
||||
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
|
||||
if (sortBy === 'Name') {
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
// None should always be first
|
||||
if (a[0] === 'None' && b[0] !== 'None') {
|
||||
return -1
|
||||
}
|
||||
if (a[0] !== 'None' && b[0] === 'None') {
|
||||
return 1
|
||||
}
|
||||
return a[0].localeCompare(b[0])
|
||||
})
|
||||
instanceMap.clear()
|
||||
sortedEntries.forEach((entry) => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
|
||||
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
|
||||
if (group === 'Game version') {
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
return a[0].localeCompare(b[0], undefined, { numeric: true })
|
||||
})
|
||||
instanceMap.clear()
|
||||
sortedEntries.forEach((entry) => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
|
||||
return instanceMap
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
clearable
|
||||
wrapper-class="flex-1"
|
||||
/>
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="state.sortBy"
|
||||
name="Sort Dropdown"
|
||||
class="max-w-[16rem]"
|
||||
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
|
||||
placeholder="Select..."
|
||||
>
|
||||
<span class="font-semibold text-primary">Sort by: </span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
</DropdownSelect>
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="state.group"
|
||||
class="max-w-[16rem]"
|
||||
name="Group Dropdown"
|
||||
:options="['Group', 'Loader', 'Game version', 'None']"
|
||||
placeholder="Select..."
|
||||
>
|
||||
<span class="font-semibold text-primary">Group by: </span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
</DropdownSelect>
|
||||
</div>
|
||||
<Accordion
|
||||
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}))"
|
||||
:key="instanceSection.key"
|
||||
:divider="instanceSection.key !== 'None'"
|
||||
:open-by-default="!isSectionCollapsed(instanceSection.key)"
|
||||
class="row"
|
||||
@on-open="setSectionCollapsed(instanceSection.key, false)"
|
||||
@on-close="setSectionCollapsed(instanceSection.key, true)"
|
||||
>
|
||||
<template v-if="instanceSection.key !== 'None'" #title>
|
||||
<span class="text-base">{{ instanceSection.key }}</span>
|
||||
</template>
|
||||
<section class="instances">
|
||||
<Instance
|
||||
v-for="instance in instanceSection.value"
|
||||
ref="instanceComponents"
|
||||
:key="instance.id + instance.install_stage"
|
||||
:instance="instance"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.id)"
|
||||
/>
|
||||
</section>
|
||||
</Accordion>
|
||||
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteInstance" />
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EyeIcon /> View instance </template>
|
||||
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
<template #open> <FolderOpenIcon /> Open folder </template>
|
||||
<template #copy> <ClipboardCopyIcon /> Copy path </template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.instances {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
width: 100%;
|
||||
gap: 0.75rem;
|
||||
margin-right: auto;
|
||||
scroll-behavior: smooth;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,371 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
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'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
canPaginate: Boolean,
|
||||
})
|
||||
|
||||
const actualInstances = computed(() =>
|
||||
props.instances.filter(
|
||||
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
|
||||
),
|
||||
)
|
||||
|
||||
const modsRow = ref(null)
|
||||
const instanceOptions = ref(null)
|
||||
const instanceComponents = ref(null)
|
||||
const rows = ref(null)
|
||||
const deleteConfirmModal = ref(null)
|
||||
|
||||
const currentDeleteInstance = ref(null)
|
||||
|
||||
async function deleteInstance() {
|
||||
if (currentDeleteInstance.value) {
|
||||
await remove(currentDeleteInstance.value).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleInstanceRightClick = async (event, passedInstance) => {
|
||||
const baseOptions = [
|
||||
...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open_folder' },
|
||||
{ name: 'copy_path' },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
name: 'delete',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError)
|
||||
|
||||
const options =
|
||||
runningProcesses.length > 0
|
||||
? [
|
||||
{
|
||||
name: 'stop',
|
||||
color: 'danger',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
: [
|
||||
...(passedInstance.quarantined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
]),
|
||||
...baseOptions,
|
||||
]
|
||||
|
||||
instanceOptions.value.showMenu(event, passedInstance, options)
|
||||
}
|
||||
|
||||
const handleProjectClick = (event, passedInstance) => {
|
||||
instanceOptions.value.showMenu(event, passedInstance, [
|
||||
{
|
||||
name: 'install',
|
||||
color: 'primary',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
name: 'open_link',
|
||||
},
|
||||
{
|
||||
name: 'copy_link',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
await run(args.item.id).catch((err) => 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({
|
||||
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: args.item.id },
|
||||
})
|
||||
break
|
||||
case 'edit':
|
||||
await router.push({
|
||||
path: `/instance/${encodeURIComponent(args.item.id)}`,
|
||||
})
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item.id
|
||||
deleteConfirmModal.value.show()
|
||||
break
|
||||
case 'open_folder':
|
||||
await showInstanceInFolder(args.item.id)
|
||||
break
|
||||
case 'copy_path':
|
||||
await navigator.clipboard.writeText(args.item.id)
|
||||
break
|
||||
case 'install': {
|
||||
await installVersion(
|
||||
args.item.project_id,
|
||||
null,
|
||||
null,
|
||||
'ProjectCardContextMenu',
|
||||
() => {},
|
||||
() => {},
|
||||
).catch(handleError)
|
||||
|
||||
break
|
||||
}
|
||||
case 'open_link':
|
||||
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
|
||||
break
|
||||
case 'copy_link':
|
||||
await navigator.clipboard.writeText(
|
||||
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const maxInstancesPerCompactRow = ref(1)
|
||||
const maxInstancesPerRow = ref(1)
|
||||
const maxProjectsPerRow = ref(1)
|
||||
|
||||
const calculateCardsPerRow = () => {
|
||||
if (rows.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate how many cards fit in one row
|
||||
const containerWidth = rows.value[0].clientWidth
|
||||
// Convert container width from pixels to rem
|
||||
const containerWidthInRem =
|
||||
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
|
||||
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
|
||||
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
|
||||
if (maxInstancesPerRow.value < 5) {
|
||||
maxInstancesPerRow.value *= 2
|
||||
}
|
||||
if (maxInstancesPerCompactRow.value < 5) {
|
||||
maxInstancesPerCompactRow.value *= 2
|
||||
}
|
||||
if (maxProjectsPerRow.value < 3) {
|
||||
maxProjectsPerRow.value *= 2
|
||||
}
|
||||
}
|
||||
|
||||
const rowContainer = ref(null)
|
||||
const resizeObserver = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
calculateCardsPerRow()
|
||||
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.observe(rowContainer.value)
|
||||
}
|
||||
window.addEventListener('resize', calculateCardsPerRow)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateCardsPerRow)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.unobserve(rowContainer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteInstance" />
|
||||
<div ref="rowContainer" class="flex flex-col gap-4">
|
||||
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
|
||||
<HeadingLink class="mt-1" :to="row.route">
|
||||
{{ row.label }}
|
||||
</HeadingLink>
|
||||
<section
|
||||
v-if="row.instance"
|
||||
ref="modsRow"
|
||||
class="instances"
|
||||
:class="{ compact: row.compact }"
|
||||
>
|
||||
<Instance
|
||||
v-for="(instance, instanceIndex) in row.instances.slice(
|
||||
0,
|
||||
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
|
||||
)"
|
||||
:key="row.label + instance.id"
|
||||
:instance="instance"
|
||||
:compact="row.compact"
|
||||
:first="instanceIndex === 0"
|
||||
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
|
||||
/>
|
||||
</section>
|
||||
<section v-else ref="modsRow" class="projects">
|
||||
<LegacyProjectCard
|
||||
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
|
||||
:key="project?.project_id"
|
||||
ref="instanceComponents"
|
||||
class="item"
|
||||
:project="project"
|
||||
@contextmenu.prevent.stop="(event) => handleProjectClick(event, project)"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EyeIcon /> View instance </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
<template #open_folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #install> <DownloadIcon /> Install </template>
|
||||
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
gap: var(--gap-xs);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
a {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: bolder;
|
||||
white-space: nowrap;
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: var(--color-base);
|
||||
}
|
||||
}
|
||||
|
||||
.instances {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-gap: 0.75rem;
|
||||
width: 100%;
|
||||
|
||||
&.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.projects {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
grid-gap: 0.75rem;
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,7 @@
|
||||
<div class="flex gap-2 w-full min-w-0">
|
||||
<Avatar
|
||||
size="36px"
|
||||
disable-conditional-icon-padding
|
||||
:src="
|
||||
selectedAccount
|
||||
? avatarUrl
|
||||
@@ -46,7 +47,11 @@
|
||||
class="w-5 h-5 text-brand shrink-0"
|
||||
/>
|
||||
<RadioButtonIcon v-else class="w-5 h-5 text-secondary shrink-0" />
|
||||
<Avatar :src="getAccountAvatarUrl(account)" size="24px" />
|
||||
<Avatar
|
||||
:src="getAccountAvatarUrl(account)"
|
||||
size="24px"
|
||||
disable-conditional-icon-padding
|
||||
/>
|
||||
<p
|
||||
class="m-0 truncate min-w-0"
|
||||
:class="
|
||||
@@ -107,6 +112,7 @@ import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { handleSevereError } from '@/composables/use-error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
get_default_user,
|
||||
@@ -118,7 +124,6 @@ import {
|
||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Skin } from '@/helpers/skins'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
@@ -185,6 +190,7 @@ defineExpose({
|
||||
refreshValues,
|
||||
setEquippedSkin,
|
||||
setLoginDisabled,
|
||||
login,
|
||||
loginDisabled,
|
||||
})
|
||||
|
||||
|
||||
@@ -137,8 +137,8 @@ import {
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
type PopupNotification,
|
||||
type PopupNotificationProgressItem,
|
||||
type PopupNotificationStandard,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
@@ -150,6 +150,7 @@ import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { toError } from '@/helpers/errors'
|
||||
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'
|
||||
@@ -352,14 +353,16 @@ function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
return convertFileSrc(icon)
|
||||
}
|
||||
|
||||
function getNotification(): PopupNotification | null {
|
||||
function getNotification(): PopupNotificationStandard | null {
|
||||
if (!notificationId.value) {
|
||||
return null
|
||||
}
|
||||
const notification = popupNotificationManager
|
||||
.getNotifications()
|
||||
.find((notification) => notification.id === notificationId.value)
|
||||
return notification ?? null
|
||||
return notification?.contentType === 'standard' && notification.type === 'download'
|
||||
? notification
|
||||
: null
|
||||
}
|
||||
|
||||
function removeNotification(): void {
|
||||
@@ -377,11 +380,17 @@ function syncTerminalNotifications(): void {
|
||||
for (const terminal of terminalNotifications) {
|
||||
const popupId = terminalNotificationIds.get(terminal.id)
|
||||
let notification = popupId
|
||||
? popupNotificationManager.getNotifications().find((candidate) => candidate.id === popupId)
|
||||
? popupNotificationManager
|
||||
.getNotifications()
|
||||
.find(
|
||||
(candidate): candidate is PopupNotificationStandard =>
|
||||
candidate.id === popupId && candidate.contentType === 'standard',
|
||||
)
|
||||
: undefined
|
||||
|
||||
if (!notification) {
|
||||
notification = popupNotificationManager.addPopupNotification({
|
||||
contentType: 'standard',
|
||||
title: terminal.title,
|
||||
text: terminal.text,
|
||||
type: terminal.type,
|
||||
@@ -411,7 +420,7 @@ function syncTerminalNotifications(): void {
|
||||
function buildDownloadItems(): PopupNotificationProgressItem[] {
|
||||
return [
|
||||
...installJobNotifications.progressItems.value,
|
||||
...currentLoadingBars.value.map((bar) => ({
|
||||
...currentLoadingBars.value.map<PopupNotificationProgressItem>((bar) => ({
|
||||
id: getLoadingBarKey(bar),
|
||||
title: bar.title ?? '',
|
||||
text: getLoadingText(bar),
|
||||
@@ -451,7 +460,7 @@ function updateNotification(resummon = false): void {
|
||||
return
|
||||
}
|
||||
|
||||
let notif = getNotification()
|
||||
const notif = getNotification()
|
||||
const progressItems = buildDownloadItems()
|
||||
|
||||
if (notif) {
|
||||
@@ -463,7 +472,8 @@ function updateNotification(resummon = false): void {
|
||||
notif.progress = undefined
|
||||
notif.waiting = undefined
|
||||
} else {
|
||||
notif = popupNotificationManager.addPopupNotification({
|
||||
const notification = popupNotificationManager.addPopupNotification({
|
||||
contentType: 'standard',
|
||||
title: installJobNotifications.active.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads),
|
||||
@@ -471,7 +481,7 @@ function updateNotification(resummon = false): void {
|
||||
autoCloseMs: null,
|
||||
progressItems,
|
||||
})
|
||||
notificationId.value = notif.id
|
||||
notificationId.value = notification.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +558,7 @@ async function refreshLoadingBars() {
|
||||
|
||||
const installJobNotifications = await useInstallJobNotifications({
|
||||
router,
|
||||
handleError,
|
||||
handleError: (error) => handleError(toError(error)),
|
||||
onChange: updateNotification,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="shown"
|
||||
ref="contextMenu"
|
||||
class="context-menu"
|
||||
:style="{
|
||||
left: left,
|
||||
top: top,
|
||||
}"
|
||||
>
|
||||
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
|
||||
<hr v-if="option.type === 'divider'" class="divider" />
|
||||
<div
|
||||
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
|
||||
class="item clickable"
|
||||
:class="[option.color ?? 'base']"
|
||||
>
|
||||
<slot :name="option.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['menu-closed', 'option-clicked'])
|
||||
|
||||
const item = ref(null)
|
||||
const contextMenu = ref(null)
|
||||
const options = ref([])
|
||||
const left = ref('0px')
|
||||
const top = ref('0px')
|
||||
const shown = ref(false)
|
||||
|
||||
defineExpose({
|
||||
showMenu: (event, passedItem, passedOptions) => {
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
|
||||
// show to get dimensions
|
||||
shown.value = true
|
||||
|
||||
// then, adjust position if overflowing
|
||||
nextTick(() => {
|
||||
const menuWidth = contextMenu.value?.clientWidth || 200
|
||||
const menuHeight = contextMenu.value?.clientHeight || 100
|
||||
const minFromEdge = 10
|
||||
|
||||
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
|
||||
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
|
||||
} else {
|
||||
left.value = event.pageX + minFromEdge + 'px'
|
||||
}
|
||||
|
||||
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
|
||||
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
|
||||
} else {
|
||||
top.value = event.pageY + minFromEdge + 'px'
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const isInstanceLink = (item) => {
|
||||
if (item.instance != undefined && item.instance.link) {
|
||||
return true
|
||||
} else if (item != undefined && item.link) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const hideContextMenu = () => {
|
||||
shown.value = false
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
const optionClicked = (option) => {
|
||||
emit('option-clicked', {
|
||||
item: item.value,
|
||||
option: option,
|
||||
})
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
const onEscKeyRelease = (event) => {
|
||||
if (event.keyCode === 27) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
if (
|
||||
contextMenu.value &&
|
||||
contextMenu.value.$el !== event.target &&
|
||||
!elements.includes(contextMenu.value.$el)
|
||||
) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
document.body.addEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-floating);
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
z-index: 1000000;
|
||||
overflow: hidden;
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.item {
|
||||
align-items: center;
|
||||
color: var(--color-base);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
&.base {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--color-red);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.contrast {
|
||||
background-color: var(--color-orange);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: var(--gap-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -20,11 +20,11 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { handleSevereError } from '@/composables/use-error.js'
|
||||
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'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
FileTreeSelect,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
Input,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
Textarea,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
@@ -194,7 +195,7 @@ function normalizeExportPath(path) {
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="nameInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
|
||||
@@ -206,7 +207,7 @@ function normalizeExportPath(path) {
|
||||
<p class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.versionNumberLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="versionInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
|
||||
@@ -219,9 +220,8 @@ function normalizeExportPath(path) {
|
||||
<p class="m-0 text-contrast font-semibold">
|
||||
{{ formatMessage(commonMessages.descriptionLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
<Textarea
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
@@ -7,22 +7,39 @@ import {
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Avatar,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { handleSevereError } from '@/composables/use-error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const messages = defineMessages({
|
||||
instanceIcon: { id: 'app.instance.card.icon-alt', defaultMessage: 'Instance icon' },
|
||||
stop: { id: 'app.instance.card.stop', defaultMessage: 'Stop' },
|
||||
loading: { id: 'app.instance.card.loading', defaultMessage: 'Instance is loading...' },
|
||||
installing: { id: 'app.instance.card.installing', defaultMessage: 'Installing...' },
|
||||
play: { id: 'app.instance.card.play', defaultMessage: 'Play' },
|
||||
repair: { id: 'app.instance.card.repair', defaultMessage: 'Repair' },
|
||||
played: { id: 'app.instance.card.played', defaultMessage: 'Played {relativeTime}' },
|
||||
neverPlayed: { id: 'app.instance.card.never-played', defaultMessage: 'Never played' },
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
@@ -159,9 +176,9 @@ onMounted(() => {
|
||||
>
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
:tint-by="instance.id"
|
||||
alt="Mod card"
|
||||
:alt="formatMessage(messages.instanceIcon)"
|
||||
/>
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
<span class="line-clamp-2">{{ instance.name }}</span>
|
||||
@@ -169,10 +186,10 @@ onMounted(() => {
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
v-if="playing"
|
||||
v-tooltip="'Stop'"
|
||||
v-tooltip="formatMessage(messages.stop)"
|
||||
type="colored"
|
||||
color="red"
|
||||
:label="'Stop'"
|
||||
:label="formatMessage(messages.stop)"
|
||||
@mouseenter="checkProcess"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
>
|
||||
@@ -180,18 +197,18 @@ onMounted(() => {
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="modLoading"
|
||||
v-tooltip="'Instance is loading...'"
|
||||
:label="'Instance is loading...'"
|
||||
v-tooltip="formatMessage(messages.loading)"
|
||||
:label="formatMessage(messages.loading)"
|
||||
disabled
|
||||
>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="!instance.quarantined"
|
||||
v-tooltip="'Play'"
|
||||
v-tooltip="formatMessage(messages.play)"
|
||||
:type="first ? 'colored' : 'base'"
|
||||
:color="first ? 'brand' : undefined"
|
||||
label="Play"
|
||||
:label="formatMessage(messages.play)"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
@@ -203,9 +220,13 @@ onMounted(() => {
|
||||
<TimerIcon />
|
||||
<span class="text-sm">
|
||||
<template v-if="instance.last_played">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
{{
|
||||
formatMessage(messages.played, {
|
||||
relativeTime: formatRelativeTime(dayjs(instance.last_played).toISOString()),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,19 +240,19 @@ onMounted(() => {
|
||||
<div class="relative flex items-center justify-center">
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
:tint-by="instance.id"
|
||||
alt="Mod card"
|
||||
:alt="formatMessage(messages.instanceIcon)"
|
||||
: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">
|
||||
<IconButton
|
||||
v-if="playing"
|
||||
v-tooltip="'Stop'"
|
||||
v-tooltip="formatMessage(messages.stop)"
|
||||
type="colored"
|
||||
color="red"
|
||||
size="xl"
|
||||
:label="'Stop'"
|
||||
:label="formatMessage(messages.stop)"
|
||||
:class="{ 'scale-100 opacity-100': playing }"
|
||||
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@@ -241,17 +262,17 @@ onMounted(() => {
|
||||
</IconButton>
|
||||
<SpinnerIcon
|
||||
v-else-if="modLoading || installing"
|
||||
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
|
||||
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
|
||||
class="animate-spin w-8 h-8"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<IconButton
|
||||
v-else-if="!installed && !instance.quarantined"
|
||||
v-tooltip="'Repair'"
|
||||
v-tooltip="formatMessage(messages.repair)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="xl"
|
||||
:label="'Repair'"
|
||||
:label="formatMessage(messages.repair)"
|
||||
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
@@ -259,11 +280,11 @@ onMounted(() => {
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="!instance.quarantined"
|
||||
v-tooltip="'Play'"
|
||||
v-tooltip="formatMessage(messages.play)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="xl"
|
||||
:label="'Play'"
|
||||
:label="formatMessage(messages.play)"
|
||||
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Avatar, ButtonLink, defineMessages, FormattedTag, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getInstanceIconUrl } from '@/helpers/instance'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
backToInstance: {
|
||||
id: 'app.instance.navigation.back-to-instance',
|
||||
defaultMessage: 'Back to instance',
|
||||
},
|
||||
})
|
||||
|
||||
type Instance = {
|
||||
game_version: string
|
||||
loader: string
|
||||
@@ -31,11 +40,7 @@ const instanceLink = computed(() => {
|
||||
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
|
||||
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
:alt="instance.name"
|
||||
size="48px"
|
||||
/>
|
||||
<Avatar :src="getInstanceIconUrl(instance.icon_path)" :alt="instance.name" size="48px" />
|
||||
<span class="flex flex-col gap-2">
|
||||
<span class="font-extrabold bold text-contrast">
|
||||
{{ instance.name }}
|
||||
@@ -48,7 +53,9 @@ const instanceLink = computed(() => {
|
||||
</span>
|
||||
</span>
|
||||
</router-link>
|
||||
<ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </ButtonLink>
|
||||
<ButtonLink :to="instanceLink">
|
||||
<LeftArrowIcon /> {{ formatMessage(messages.backToInstance) }}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
|
||||
<div :id="props.id" class="toggle-setting" :class="{ compact }">
|
||||
<div class="input-with-status">
|
||||
<StyledInput
|
||||
<Input
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:model-value="props.modelValue ? props.modelValue.path : ''"
|
||||
@@ -98,13 +98,7 @@ import {
|
||||
SpinnerIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { Button, defineMessages, injectNotificationManager, Input, useVIntl } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { getInstanceIconUrl, list } from '@/helpers/instance'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
|
||||
const ITEM_SIZE = 52
|
||||
const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
|
||||
const APPROX_USED_VERTICAL_SPACE = 475 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
|
||||
const STORAGE_KEY = 'modrinth-quick-instance-count'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
@@ -201,7 +200,7 @@ const dividerTooltip = computed(() => {
|
||||
>
|
||||
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
size="28px"
|
||||
:tint-by="instance.id"
|
||||
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ImportIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, IntlFormatted, useVIntl } from '@modrinth/ui'
|
||||
import { inject, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import modrinthSocialIcon from '../../assets/welcome/modrinth-social-icon.png'
|
||||
|
||||
const showCreationModal = inject<() => void>('showCreationModal')
|
||||
const showImportModal = inject<() => void>('showImportModal')
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
welcomeTitle: {
|
||||
id: 'app.welcome-screen.title',
|
||||
defaultMessage: 'Welcome to Modrinth',
|
||||
},
|
||||
welcomeDescription: {
|
||||
id: 'app.welcome-screen.description',
|
||||
defaultMessage: 'Ready to start playing?',
|
||||
},
|
||||
createInstance: {
|
||||
id: 'app.welcome-screen.create-instance',
|
||||
defaultMessage: 'Create an instance',
|
||||
},
|
||||
quickCreateHint: {
|
||||
id: 'app.welcome-screen.quick-create-hint',
|
||||
defaultMessage: 'Press <shortcut>N</shortcut> to quick create an instance',
|
||||
},
|
||||
importPrompt: {
|
||||
id: 'app.welcome-screen.import-prompt',
|
||||
defaultMessage: 'Escaping another launcher?',
|
||||
},
|
||||
importFromLauncher: {
|
||||
id: 'app.welcome-screen.import-from-launcher',
|
||||
defaultMessage: 'Import from launcher',
|
||||
},
|
||||
})
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
|
||||
function handleOffline() {
|
||||
offline.value = true
|
||||
}
|
||||
|
||||
function handleOnline() {
|
||||
offline.value = false
|
||||
}
|
||||
|
||||
function handleQuickCreate(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (
|
||||
event.key.toLowerCase() !== 'n' ||
|
||||
event.repeat ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
target?.isContentEditable ||
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!offline.value) {
|
||||
event.preventDefault()
|
||||
showCreationModal?.()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('offline', handleOffline)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('keydown', handleQuickCreate)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('keydown', handleQuickCreate)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col min-h-full px-6 pb-6 pt-16">
|
||||
<div class="relative flex grow items-center justify-center">
|
||||
<div class="relative isolate flex flex-col items-center gap-6">
|
||||
<div
|
||||
class="dot-pattern pointer-events-none absolute left-1/2 -top-52 -z-10 h-[29.875rem] w-[min(25.9375rem,80vw)] -translate-x-1/2 rounded-2xl [@media(max-height:700px)]:h-[23rem]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="size-[6.25rem]">
|
||||
<img :src="modrinthSocialIcon" alt="" class="pointer-events-none size-full" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<h1 class="m-0 flex items-center gap-2 text-2xl font-semibold leading-8 text-contrast">
|
||||
{{ formatMessage(messages.welcomeTitle) }}
|
||||
</h1>
|
||||
<p class="m-0 text-center text-base leading-6 text-primary">
|
||||
{{ formatMessage(messages.welcomeDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex w-72 flex-col items-center gap-4">
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="lg"
|
||||
class="!shadow-none"
|
||||
:disabled="offline"
|
||||
@click="showCreationModal?.()"
|
||||
>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.createInstance) }}
|
||||
</Button>
|
||||
<span class="flex items-center gap-1 text-sm leading-5 text-secondary">
|
||||
<IntlFormatted :message-id="messages.quickCreateHint">
|
||||
<template #shortcut="{ children }">
|
||||
<kbd
|
||||
class="inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-solid border-surface-5 bg-button-bg px-1 text-xs font-normal leading-4 text-primary"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</kbd>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col h-max items-center justify-end gap-4 text-sm leading-5 text-secondary"
|
||||
>
|
||||
<span class="whitespace-nowrap">{{ formatMessage(messages.importPrompt) }}</span>
|
||||
<Button size="lg" class="!font-medium" :disabled="offline" @click="showImportModal?.()">
|
||||
<ImportIcon />
|
||||
{{ formatMessage(messages.importFromLauncher) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dot-pattern {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--color-text-primary) 25%, transparent) 0.5px,
|
||||
transparent 0.75px
|
||||
);
|
||||
background-size: 0.5625rem 0.5625rem;
|
||||
opacity: 0.8;
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
|
||||
mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
@@ -39,17 +39,17 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
import { getOS } from '@/helpers/utils.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const appSettings = useAppSettings()
|
||||
|
||||
const nativeDecorations = ref(true)
|
||||
const isMaximized = ref(false)
|
||||
const os = ref('')
|
||||
|
||||
const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
|
||||
const alwaysShowAppControls = computed(() => appSettings.getFeatureFlag('always_show_app_controls'))
|
||||
|
||||
const showControls = computed(
|
||||
() =>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="showUpdatePill"
|
||||
type="outlined"
|
||||
native-type="button"
|
||||
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out !text-brand [&>svg]:!text-brand !shadow-[inset_0_0_0_1px_var(--color-brand)] hover:!bg-brand focus-visible:!bg-brand hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
|
||||
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out !text-brand [&>svg]:!text-inherit !shadow-[inset_0_0_0_1px_var(--color-brand)] hover:!bg-brand focus-visible:!bg-brand hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
|
||||
:class="{
|
||||
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
|
||||
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<Teleport to="#teleports">
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="shown && !isMobileActiveSubmenu"
|
||||
ref="contextMenu"
|
||||
class="context-menu"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
@keydown="handleMenuKeydown"
|
||||
@mousemove="(event) => handleMenuMouseMove(event, 'menu')"
|
||||
>
|
||||
<template v-for="(option, index) in options" :key="index">
|
||||
<hr v-if="isDivider(option)" class="divider" />
|
||||
<button
|
||||
v-else-if="isOptionVisible(option)"
|
||||
:ref="(element) => setOptionButtonRef(index, element)"
|
||||
type="button"
|
||||
class="item clickable"
|
||||
:class="[
|
||||
option.color ?? 'base',
|
||||
{
|
||||
active: index === activeOptionIndex,
|
||||
'safe-triangle-hover': index === pendingOptionIndex,
|
||||
},
|
||||
]"
|
||||
role="menuitem"
|
||||
:aria-haspopup="hasChildren(option) ? 'menu' : undefined"
|
||||
:aria-expanded="hasChildren(option) ? index === activeOptionIndex : undefined"
|
||||
@click.stop="handleOptionClick(option, index)"
|
||||
@focus="handleOptionFocus(option, index)"
|
||||
@mouseenter="handleOptionMouseEnter(option, index)"
|
||||
>
|
||||
<span class="item-content"><slot :name="option.name" /></span>
|
||||
<ChevronRightIcon v-if="hasChildren(option)" class="submenu-chevron" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="shown && activeOption && hasSubmenuPosition"
|
||||
ref="submenu"
|
||||
class="context-menu submenu"
|
||||
:style="submenuStyle"
|
||||
role="menu"
|
||||
@keydown="handleSubmenuKeydown"
|
||||
@mouseenter="handleSubmenuMouseEnter"
|
||||
@mouseleave="isCursorInsideSubmenu = false"
|
||||
@mousemove="(event) => handleMenuMouseMove(event, 'submenu')"
|
||||
>
|
||||
<button
|
||||
v-if="isMobileSubmenuLayout"
|
||||
type="button"
|
||||
class="item clickable base mobile-back"
|
||||
@click.stop="returnToMenu"
|
||||
>
|
||||
<ChevronLeftIcon class="submenu-chevron" />
|
||||
<span class="item-content"><slot :name="activeOption.name" /></span>
|
||||
</button>
|
||||
<template v-for="(option, index) in activeOption.children" :key="index">
|
||||
<hr v-if="isDivider(option)" class="divider" />
|
||||
<button
|
||||
v-else-if="isOptionVisible(option)"
|
||||
:ref="(element) => setSubmenuButtonRef(index, element)"
|
||||
type="button"
|
||||
class="item clickable"
|
||||
:class="option.color ?? 'base'"
|
||||
role="menuitem"
|
||||
@click.stop="optionClicked(option.name)"
|
||||
>
|
||||
<span class="item-content"><slot :name="option.name" /></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import type { ContextMenuEmit } from './types'
|
||||
import { useContextMenu } from './use-context-menu'
|
||||
import { hasChildren, isDivider } from './utils'
|
||||
|
||||
const emit = defineEmits<ContextMenuEmit>()
|
||||
|
||||
const {
|
||||
shown,
|
||||
contextMenu,
|
||||
submenu,
|
||||
options,
|
||||
menuStyle,
|
||||
submenuStyle,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
pendingOptionIndex,
|
||||
hasSubmenuPosition,
|
||||
isMobileSubmenuLayout,
|
||||
isMobileActiveSubmenu,
|
||||
isCursorInsideSubmenu,
|
||||
isOptionVisible,
|
||||
setOptionButtonRef,
|
||||
setSubmenuButtonRef,
|
||||
showMenu,
|
||||
hideMenu,
|
||||
handleOptionClick,
|
||||
optionClicked,
|
||||
handleOptionFocus,
|
||||
handleOptionMouseEnter,
|
||||
returnToMenu,
|
||||
handleSubmenuMouseEnter,
|
||||
handleMenuMouseMove,
|
||||
handleMenuKeydown,
|
||||
handleSubmenuKeydown,
|
||||
} = useContextMenu(emit)
|
||||
|
||||
defineExpose({ showMenu, hideMenu })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-floating);
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
z-index: 1000000;
|
||||
overflow: hidden;
|
||||
padding: var(--gap-sm);
|
||||
min-width: 12rem;
|
||||
max-width: calc(100vw - 1.25rem);
|
||||
|
||||
&.submenu {
|
||||
z-index: 1000001;
|
||||
}
|
||||
|
||||
.item {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 500;
|
||||
gap: var(--gap-sm);
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
||||
.item-content {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.submenu-chevron {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: -0.25rem;
|
||||
}
|
||||
|
||||
:deep(svg) {
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
&:hover:not(.safe-triangle-hover),
|
||||
&:active,
|
||||
&:focus-visible,
|
||||
&.active {
|
||||
:deep(svg) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&.base {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--color-red);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.contrast {
|
||||
background-color: var(--color-orange);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-back {
|
||||
border-bottom: 1px solid var(--color-divider);
|
||||
border-radius: 0;
|
||||
margin: calc(var(--gap-sm) * -1) calc(var(--gap-sm) * -1) var(--gap-sm);
|
||||
width: calc(100% + var(--gap-sm) * 2);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: var(--gap-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
export type ContextMenuDivider = {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type ContextMenuAction = {
|
||||
name: string
|
||||
color?: string
|
||||
children?: ContextMenuOption[]
|
||||
}
|
||||
|
||||
export type ContextMenuParentAction = ContextMenuAction & {
|
||||
children: ContextMenuOption[]
|
||||
}
|
||||
|
||||
export type ContextMenuOption = ContextMenuDivider | ContextMenuAction
|
||||
|
||||
export type ContextMenuSelection = {
|
||||
item: unknown
|
||||
option: string
|
||||
}
|
||||
|
||||
export type ContextMenuEmit = {
|
||||
(event: 'menu-closed'): void
|
||||
(event: 'option-clicked', selection: ContextMenuSelection): void
|
||||
}
|
||||
|
||||
export type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type ViewportRect = {
|
||||
width: number
|
||||
height: number
|
||||
offsetTop: number
|
||||
offsetLeft: number
|
||||
}
|
||||
|
||||
export type ButtonRefElement = Element | ComponentPublicInstance | null
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { ContextMenuParentAction, Point, ViewportRect } from './types'
|
||||
|
||||
const MENU_GAP = 8
|
||||
const VIEWPORT_MARGIN = 10
|
||||
|
||||
type ContextMenuPositionOptions = {
|
||||
shown: Ref<boolean>
|
||||
activeOption: Ref<ContextMenuParentAction | null>
|
||||
activeOptionIndex: Ref<number | null>
|
||||
isMobileSubmenuLayout: Ref<boolean>
|
||||
contextMenu: Ref<HTMLElement | null>
|
||||
submenu: Ref<HTMLElement | null>
|
||||
optionButtonRefs: Map<number, HTMLElement>
|
||||
}
|
||||
|
||||
export function useContextMenuPosition({
|
||||
shown,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
isMobileSubmenuLayout,
|
||||
contextMenu,
|
||||
submenu,
|
||||
optionButtonRefs,
|
||||
}: ContextMenuPositionOptions) {
|
||||
const menuStyle = ref<CSSProperties>({ left: '0px', top: '0px' })
|
||||
const menuAnchor = ref<Point>({ x: 0, y: 0 })
|
||||
const submenuPosition = ref<Point>({ x: 0, y: 0 })
|
||||
const hasSubmenuPosition = ref(false)
|
||||
let positionRafId: number | null = null
|
||||
|
||||
const submenuStyle = computed<CSSProperties>(() => {
|
||||
if (isMobileSubmenuLayout.value) return menuStyle.value
|
||||
|
||||
return {
|
||||
left: `${submenuPosition.value.x}px`,
|
||||
top: `${submenuPosition.value.y}px`,
|
||||
}
|
||||
})
|
||||
|
||||
function updateMenuPosition(x: number, y: number) {
|
||||
if (!contextMenu.value) return
|
||||
|
||||
const viewport = getViewportRect()
|
||||
const menuRect = contextMenu.value.getBoundingClientRect()
|
||||
const viewportLeft = viewport.offsetLeft
|
||||
const viewportTop = viewport.offsetTop
|
||||
const viewportRight = viewport.offsetLeft + viewport.width
|
||||
const viewportBottom = viewport.offsetTop + viewport.height
|
||||
const anchorX = x + viewport.offsetLeft
|
||||
const anchorY = y + viewport.offsetTop
|
||||
const left = Math.min(
|
||||
Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP),
|
||||
Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN),
|
||||
)
|
||||
const top = Math.min(
|
||||
Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP),
|
||||
Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN),
|
||||
)
|
||||
|
||||
menuStyle.value = { left: `${left}px`, top: `${top}px` }
|
||||
if (activeOption.value) scheduleSubmenuPositionUpdate()
|
||||
}
|
||||
|
||||
function updateSubmenuPosition() {
|
||||
if (!activeOption.value || activeOptionIndex.value === null) return false
|
||||
|
||||
if (isMobileSubmenuLayout.value) {
|
||||
hasSubmenuPosition.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
const optionButton = optionButtonRefs.get(activeOptionIndex.value)
|
||||
if (!optionButton || !contextMenu.value) return false
|
||||
|
||||
const viewport = getViewportRect()
|
||||
const buttonRect = optionButton.getBoundingClientRect()
|
||||
const menuRect = contextMenu.value.getBoundingClientRect()
|
||||
const submenuRect = submenu.value?.getBoundingClientRect()
|
||||
const submenuWidth = submenuRect?.width ?? menuRect.width
|
||||
const submenuHeight = submenuRect?.height ?? 100
|
||||
const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport)
|
||||
const preferredLeft =
|
||||
direction === 'right'
|
||||
? buttonRect.right + MENU_GAP
|
||||
: buttonRect.left - submenuWidth - MENU_GAP
|
||||
const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN
|
||||
const maxLeft = Math.max(
|
||||
minLeft,
|
||||
viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN,
|
||||
)
|
||||
const minTop = viewport.offsetTop + VIEWPORT_MARGIN
|
||||
const maxTop = Math.max(
|
||||
minTop,
|
||||
viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN,
|
||||
)
|
||||
|
||||
submenuPosition.value = {
|
||||
x: Math.min(Math.max(minLeft, preferredLeft), maxLeft),
|
||||
y: Math.min(Math.max(minTop, buttonRect.top), maxTop),
|
||||
}
|
||||
hasSubmenuPosition.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleSubmenuPositionUpdate(retries = 8) {
|
||||
nextTick(() => {
|
||||
if (!shown.value || !activeOption.value) return
|
||||
|
||||
const hasRenderedSubmenu = submenu.value !== null
|
||||
if (updateSubmenuPosition()) {
|
||||
if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition)
|
||||
return
|
||||
}
|
||||
|
||||
if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0)
|
||||
})
|
||||
}
|
||||
|
||||
function schedulePositionUpdate() {
|
||||
if (!shown.value || positionRafId !== null) return
|
||||
|
||||
positionRafId = window.requestAnimationFrame(() => {
|
||||
positionRafId = null
|
||||
updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y)
|
||||
})
|
||||
}
|
||||
|
||||
function cancelPositionUpdate() {
|
||||
if (positionRafId !== null) window.cancelAnimationFrame(positionRafId)
|
||||
}
|
||||
|
||||
return {
|
||||
menuStyle,
|
||||
menuAnchor,
|
||||
submenuStyle,
|
||||
hasSubmenuPosition,
|
||||
updateMenuPosition,
|
||||
scheduleSubmenuPositionUpdate,
|
||||
schedulePositionUpdate,
|
||||
cancelPositionUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
function getViewportRect(): ViewportRect {
|
||||
const visualViewport = window.visualViewport
|
||||
return {
|
||||
width: visualViewport?.width ?? window.innerWidth,
|
||||
height: visualViewport?.height ?? window.innerHeight,
|
||||
offsetTop: visualViewport?.offsetTop ?? 0,
|
||||
offsetLeft: visualViewport?.offsetLeft ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubmenuOpenDirection(
|
||||
menuRect: DOMRect,
|
||||
submenuWidth: number,
|
||||
viewport: ViewportRect,
|
||||
): 'left' | 'right' {
|
||||
const viewportLeft = viewport.offsetLeft
|
||||
const viewportRight = viewport.offsetLeft + viewport.width
|
||||
const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN
|
||||
const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN
|
||||
|
||||
if (rightSpace >= submenuWidth) return 'right'
|
||||
if (leftSpace >= submenuWidth) return 'left'
|
||||
return rightSpace >= leftSpace ? 'right' : 'left'
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
ButtonRefElement,
|
||||
ContextMenuAction,
|
||||
ContextMenuEmit,
|
||||
ContextMenuOption,
|
||||
ContextMenuParentAction,
|
||||
Point,
|
||||
} from './types'
|
||||
import { useContextMenuPosition } from './use-context-menu-position'
|
||||
import {
|
||||
focusRelativeButton,
|
||||
getFocusableButtons,
|
||||
hasChildren,
|
||||
isAction,
|
||||
isInstanceLink,
|
||||
isPointInTriangle,
|
||||
} from './utils'
|
||||
|
||||
const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
|
||||
const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open'
|
||||
|
||||
export function useContextMenu(emit: ContextMenuEmit) {
|
||||
const item = ref<unknown>(null)
|
||||
const contextMenu = ref<HTMLElement | null>(null)
|
||||
const submenu = ref<HTMLElement | null>(null)
|
||||
const options = ref<ContextMenuOption[]>([])
|
||||
const shown = ref(false)
|
||||
const activeOptionIndex = ref<number | null>(null)
|
||||
const pendingOptionIndex = ref<number | null>(null)
|
||||
const isCursorInsideSubmenu = ref(false)
|
||||
const isMobileSubmenuLayout = ref(false)
|
||||
const lastMousePosition = ref<Point | null>(null)
|
||||
const contextMenuId = Symbol()
|
||||
const optionButtonRefs = new Map<number, HTMLElement>()
|
||||
const submenuButtonRefs = new Map<number, HTMLElement>()
|
||||
let previousMousePosition: Point | null = null
|
||||
let pendingOptionTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let mobileSubmenuMediaQuery: MediaQueryList | null = null
|
||||
|
||||
const activeOption = computed<ContextMenuParentAction | null>(() => {
|
||||
if (activeOptionIndex.value === null) return null
|
||||
const option = options.value[activeOptionIndex.value]
|
||||
return option && isAction(option) && hasChildren(option) ? option : null
|
||||
})
|
||||
|
||||
const {
|
||||
menuStyle,
|
||||
menuAnchor,
|
||||
submenuStyle,
|
||||
hasSubmenuPosition,
|
||||
updateMenuPosition,
|
||||
scheduleSubmenuPositionUpdate,
|
||||
schedulePositionUpdate,
|
||||
cancelPositionUpdate,
|
||||
} = useContextMenuPosition({
|
||||
shown,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
isMobileSubmenuLayout,
|
||||
contextMenu,
|
||||
submenu,
|
||||
optionButtonRefs,
|
||||
})
|
||||
|
||||
const isMobileActiveSubmenu = computed(
|
||||
() => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value,
|
||||
)
|
||||
|
||||
function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction {
|
||||
return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content')
|
||||
}
|
||||
|
||||
function setOptionButtonRef(index: number, element: ButtonRefElement) {
|
||||
setButtonRef(optionButtonRefs, index, element)
|
||||
}
|
||||
|
||||
function setSubmenuButtonRef(index: number, element: ButtonRefElement) {
|
||||
setButtonRef(submenuButtonRefs, index, element)
|
||||
}
|
||||
|
||||
function hideMenu() {
|
||||
if (!shown.value) return
|
||||
|
||||
shown.value = false
|
||||
deactivateSubmenu()
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) {
|
||||
window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId }))
|
||||
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
menuAnchor.value = { x: event.clientX, y: event.clientY }
|
||||
shown.value = true
|
||||
deactivateSubmenu()
|
||||
syncMobileSubmenuLayout()
|
||||
nextTick(() => updateMenuPosition(event.clientX, event.clientY))
|
||||
}
|
||||
|
||||
function handleOptionClick(option: ContextMenuAction, index: number) {
|
||||
if (hasChildren(option)) {
|
||||
activateSubmenu(index)
|
||||
return
|
||||
}
|
||||
|
||||
optionClicked(option.name)
|
||||
}
|
||||
|
||||
function optionClicked(option: string) {
|
||||
emit('option-clicked', { item: item.value, option })
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
function handleOptionFocus(option: ContextMenuAction, index: number) {
|
||||
if (hasChildren(option) && !isMobileSubmenuLayout.value) {
|
||||
activateSubmenu(index)
|
||||
} else if (!hasChildren(option)) {
|
||||
deactivateSubmenu()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOptionMouseEnter(option: ContextMenuAction, index: number) {
|
||||
if (isMobileSubmenuLayout.value) return
|
||||
|
||||
if (activeOptionIndex.value === null) {
|
||||
if (hasChildren(option)) activateSubmenu(index)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeOptionIndex.value === index) return
|
||||
|
||||
if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) {
|
||||
commitHoveredOption(index)
|
||||
return
|
||||
}
|
||||
|
||||
pendingOptionIndex.value = index
|
||||
clearPendingOptionTimeout()
|
||||
pendingOptionTimeout = setTimeout(() => {
|
||||
if (pendingOptionIndex.value !== index) return
|
||||
if (isCursorInsideSubmenu.value) {
|
||||
pendingOptionIndex.value = null
|
||||
return
|
||||
}
|
||||
|
||||
commitHoveredOption(index)
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function commitHoveredOption(index: number) {
|
||||
const option = options.value[index]
|
||||
if (option && hasChildren(option)) {
|
||||
activateSubmenu(index)
|
||||
} else {
|
||||
deactivateSubmenu()
|
||||
}
|
||||
}
|
||||
|
||||
function activateSubmenu(index: number) {
|
||||
clearPendingOptionTimeout()
|
||||
pendingOptionIndex.value = null
|
||||
activeOptionIndex.value = index
|
||||
hasSubmenuPosition.value = false
|
||||
scheduleSubmenuPositionUpdate()
|
||||
}
|
||||
|
||||
function deactivateSubmenu() {
|
||||
clearPendingOptionTimeout()
|
||||
activeOptionIndex.value = null
|
||||
pendingOptionIndex.value = null
|
||||
hasSubmenuPosition.value = false
|
||||
isCursorInsideSubmenu.value = false
|
||||
lastMousePosition.value = null
|
||||
previousMousePosition = null
|
||||
}
|
||||
|
||||
function returnToMenu() {
|
||||
const previousIndex = activeOptionIndex.value
|
||||
deactivateSubmenu()
|
||||
nextTick(() => {
|
||||
if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function handleSubmenuMouseEnter() {
|
||||
isCursorInsideSubmenu.value = true
|
||||
clearPendingOptionTimeout()
|
||||
pendingOptionIndex.value = null
|
||||
}
|
||||
|
||||
function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') {
|
||||
previousMousePosition = lastMousePosition.value
|
||||
lastMousePosition.value = { x: event.clientX, y: event.clientY }
|
||||
|
||||
if (
|
||||
source === 'menu' &&
|
||||
pendingOptionIndex.value !== null &&
|
||||
!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)
|
||||
) {
|
||||
commitHoveredOption(pendingOptionIndex.value)
|
||||
}
|
||||
}
|
||||
|
||||
function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) {
|
||||
const submenuRect = submenu.value?.getBoundingClientRect()
|
||||
if (!submenuRect || !cursor || !origin) return false
|
||||
|
||||
const submenuTargetX =
|
||||
origin.x <= submenuRect.left
|
||||
? submenuRect.left
|
||||
: origin.x >= submenuRect.right
|
||||
? submenuRect.right
|
||||
: cursor.x <= submenuRect.left
|
||||
? submenuRect.left
|
||||
: submenuRect.right
|
||||
const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 }
|
||||
const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 }
|
||||
|
||||
return isPointInTriangle(cursor, origin, upperTarget, lowerTarget)
|
||||
}
|
||||
|
||||
function handleMenuKeydown(event: KeyboardEvent) {
|
||||
const buttons = getFocusableButtons(optionButtonRefs)
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault()
|
||||
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
const focusedIndex = [...optionButtonRefs.entries()].find(
|
||||
([, button]) => button === document.activeElement,
|
||||
)?.[0]
|
||||
const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex]
|
||||
if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) {
|
||||
event.preventDefault()
|
||||
activateSubmenu(focusedIndex)
|
||||
nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus())
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
hideMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmenuKeydown(event: KeyboardEvent) {
|
||||
const buttons = getFocusableButtons(submenuButtonRefs)
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault()
|
||||
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
returnToMenu()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
hideMenu()
|
||||
}
|
||||
}
|
||||
|
||||
function syncMobileSubmenuLayout(event?: MediaQueryListEvent) {
|
||||
isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||
if (shown.value && event.key === 'Escape') hideMenu()
|
||||
}
|
||||
|
||||
function handleContextMenuOpen(event: Event) {
|
||||
if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu()
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY)
|
||||
syncMobileSubmenuLayout()
|
||||
mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout)
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('resize', schedulePositionUpdate)
|
||||
window.addEventListener('scroll', schedulePositionUpdate, true)
|
||||
window.visualViewport?.addEventListener('scroll', schedulePositionUpdate)
|
||||
window.visualViewport?.addEventListener('resize', schedulePositionUpdate)
|
||||
window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
|
||||
document.addEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPendingOptionTimeout()
|
||||
cancelPositionUpdate()
|
||||
mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout)
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('resize', schedulePositionUpdate)
|
||||
window.removeEventListener('scroll', schedulePositionUpdate, true)
|
||||
window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate)
|
||||
window.visualViewport?.removeEventListener('resize', schedulePositionUpdate)
|
||||
window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
|
||||
document.removeEventListener('keydown', handleDocumentKeydown)
|
||||
})
|
||||
|
||||
function clearPendingOptionTimeout() {
|
||||
if (!pendingOptionTimeout) return
|
||||
clearTimeout(pendingOptionTimeout)
|
||||
pendingOptionTimeout = null
|
||||
}
|
||||
|
||||
return {
|
||||
shown,
|
||||
contextMenu,
|
||||
submenu,
|
||||
options,
|
||||
menuStyle,
|
||||
submenuStyle,
|
||||
activeOption,
|
||||
activeOptionIndex,
|
||||
pendingOptionIndex,
|
||||
hasSubmenuPosition,
|
||||
isMobileSubmenuLayout,
|
||||
isMobileActiveSubmenu,
|
||||
isCursorInsideSubmenu,
|
||||
isOptionVisible,
|
||||
setOptionButtonRef,
|
||||
setSubmenuButtonRef,
|
||||
showMenu,
|
||||
hideMenu,
|
||||
handleOptionClick,
|
||||
optionClicked,
|
||||
handleOptionFocus,
|
||||
handleOptionMouseEnter,
|
||||
returnToMenu,
|
||||
handleSubmenuMouseEnter,
|
||||
handleMenuMouseMove,
|
||||
handleMenuKeydown,
|
||||
handleSubmenuKeydown,
|
||||
}
|
||||
}
|
||||
|
||||
function setButtonRef(
|
||||
buttonRefs: Map<number, HTMLElement>,
|
||||
index: number,
|
||||
element: ButtonRefElement,
|
||||
) {
|
||||
if (element instanceof HTMLElement) {
|
||||
buttonRefs.set(index, element)
|
||||
} else {
|
||||
buttonRefs.delete(index)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ContextMenuAction,
|
||||
ContextMenuDivider,
|
||||
ContextMenuOption,
|
||||
ContextMenuParentAction,
|
||||
Point,
|
||||
} from './types'
|
||||
|
||||
export function isAction(option: ContextMenuOption): option is ContextMenuAction {
|
||||
return 'name' in option
|
||||
}
|
||||
|
||||
export function isDivider(option: ContextMenuOption): option is ContextMenuDivider {
|
||||
return 'type' in option && option.type === 'divider'
|
||||
}
|
||||
|
||||
export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction {
|
||||
return isAction(option) && Boolean(option.children?.length)
|
||||
}
|
||||
|
||||
export function isInstanceLink(value: unknown) {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
if ('instance' in value) {
|
||||
const instance = value.instance
|
||||
return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link)
|
||||
}
|
||||
|
||||
return 'link' in value && Boolean(value.link)
|
||||
}
|
||||
|
||||
export function getFocusableButtons(buttonRefs: Map<number, HTMLElement>) {
|
||||
return [...buttonRefs.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, button]) => button)
|
||||
.filter((button) => button.offsetParent !== null)
|
||||
}
|
||||
|
||||
export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) {
|
||||
if (!buttons.length) return
|
||||
|
||||
const currentIndex = buttons.indexOf(document.activeElement as HTMLElement)
|
||||
const nextIndex =
|
||||
currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction
|
||||
buttons[(nextIndex + buttons.length) % buttons.length]?.focus()
|
||||
}
|
||||
|
||||
export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) {
|
||||
const area = triangleArea(a, b, c)
|
||||
const area1 = triangleArea(point, b, c)
|
||||
const area2 = triangleArea(a, point, c)
|
||||
const area3 = triangleArea(a, b, point)
|
||||
|
||||
return Math.abs(area - (area1 + area2 + area3)) < 0.5
|
||||
}
|
||||
|
||||
function triangleArea(a: Point, b: Point, c: Point) {
|
||||
return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2)
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
Input,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -15,17 +15,17 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { useFriends } from '@/composables/use-friends'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const themeStore = useTheming()
|
||||
const appSettings = useAppSettings()
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: ModrinthCredentials | null
|
||||
@@ -39,11 +39,11 @@ type FriendsSectionCollapsedFlag =
|
||||
| 'friends_pending_collapsed'
|
||||
|
||||
function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
|
||||
return themeStore.getFeatureFlag(flag)
|
||||
return appSettings.getFeatureFlag(flag)
|
||||
}
|
||||
|
||||
function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
|
||||
themeStore.featureFlags[flag] = collapsed
|
||||
appSettings.featureFlags[flag] = collapsed
|
||||
getSettings()
|
||||
.then((settings) => {
|
||||
settings.feature_flags[flag] = collapsed
|
||||
@@ -250,7 +250,7 @@ const messages = defineMessages({
|
||||
{{ formatMessage(messages.usernameDescription) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="username"
|
||||
:icon="UserIcon"
|
||||
type="text"
|
||||
@@ -280,7 +280,7 @@ const messages = defineMessages({
|
||||
>
|
||||
<UserPlusIcon />
|
||||
</IconButton>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
<script setup>
|
||||
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { Admonition, Avatar, Button, injectNotificationManager, StyledInput } from '@modrinth/ui'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
Button,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
Input,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { getInstanceIconUrl, list } from '@/helpers/instance'
|
||||
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.instance.add-server.title', defaultMessage: 'Add server to instance' },
|
||||
compatibilityWarning: {
|
||||
id: 'app.instance.add-server.compatibility-warning',
|
||||
defaultMessage: 'This server may not be compatible with all instances.',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'app.instance.add-server.search-placeholder',
|
||||
defaultMessage: 'Search for an instance',
|
||||
},
|
||||
adding: { id: 'app.instance.add-server.adding', defaultMessage: 'Adding...' },
|
||||
added: { id: 'app.instance.add-server.added', defaultMessage: 'Added' },
|
||||
add: { id: 'app.instance.add-server.add', defaultMessage: 'Add' },
|
||||
cancel: { id: 'app.instance.add-server.cancel', defaultMessage: 'Cancel' },
|
||||
})
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const modal = ref()
|
||||
@@ -77,14 +100,14 @@ async function addServer(instance) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="modal" header="Add server to instance">
|
||||
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="flex flex-col gap-4 min-w-[350px]">
|
||||
<Admonition type="warning" body="This server may not be compatible with all instances." />
|
||||
<StyledInput
|
||||
<Admonition type="warning" :body="formatMessage(messages.compatibilityWarning)" />
|
||||
<Input
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
placeholder="Search for an instance"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="max-h-[21rem] overflow-y-auto">
|
||||
@@ -98,21 +121,22 @@ async function addServer(instance) {
|
||||
:to="`/instance/${encodeURIComponent(instance.id)}`"
|
||||
@click="modal.hide()"
|
||||
>
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
class="mr-2 [--size:2rem]"
|
||||
/>
|
||||
<Avatar :src="getInstanceIconUrl(instance.icon_path)" class="mr-2 [--size:2rem]" />
|
||||
{{ instance.name }}
|
||||
</router-link>
|
||||
<Button :disabled="instance.added || instance.adding" @click="addServer(instance)">
|
||||
<PlusIcon v-if="!instance.added && !instance.adding" />
|
||||
<CheckIcon v-else-if="instance.added" />
|
||||
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
|
||||
{{
|
||||
formatMessage(
|
||||
instance.adding ? messages.adding : instance.added ? messages.added : messages.add,
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<Button @click="modal.hide()">Cancel</Button>
|
||||
<Button @click="modal.hide()">{{ formatMessage(messages.cancel) }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
import { UnknownFileWarningModal } from '@modrinth/ui'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { type FeatureFlag, useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings'
|
||||
import { useTheming } from '@/store/state'
|
||||
import type { FeatureFlag } from '@/store/theme.ts'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const appSettings = useAppSettings()
|
||||
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
@@ -34,7 +33,7 @@ function show(
|
||||
fileName.value = selectedFileName
|
||||
externalFilesInModpack.value = selectedExternalFiles
|
||||
|
||||
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
|
||||
if (appSettings.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
|
||||
void createInstance()
|
||||
return
|
||||
}
|
||||
@@ -50,7 +49,7 @@ function reset() {
|
||||
|
||||
async function proceed(dontShowAgain: boolean) {
|
||||
if (dontShowAgain) {
|
||||
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
|
||||
appSettings.featureFlags[skipUnknownPackWarningFeatureFlag] = true
|
||||
const settings = await getSettings()
|
||||
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
|
||||
await setSettings(settings)
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
|
||||
|
||||
import backpack from '@/assets/instance-icons/backpack.png'
|
||||
import beacon from '@/assets/instance-icons/beacon.png'
|
||||
import blueShark from '@/assets/instance-icons/blue-shark.png'
|
||||
import bookshelf from '@/assets/instance-icons/bookshelf.png'
|
||||
import brownBear from '@/assets/instance-icons/brown-bear.png'
|
||||
import cake from '@/assets/instance-icons/cake.png'
|
||||
import campfire from '@/assets/instance-icons/campfire.png'
|
||||
import chest from '@/assets/instance-icons/chest.png'
|
||||
import cogwheel from '@/assets/instance-icons/cogwheel.png'
|
||||
import commandBlock from '@/assets/instance-icons/command-block.png'
|
||||
import cookingPot from '@/assets/instance-icons/cooking-pot.png'
|
||||
import couch from '@/assets/instance-icons/couch.png'
|
||||
import craftingTable from '@/assets/instance-icons/crafting-table.png'
|
||||
import creeper from '@/assets/instance-icons/creeper.png'
|
||||
import enchantingTable from '@/assets/instance-icons/enchanting-table.png'
|
||||
import enderChest from '@/assets/instance-icons/ender-chest.png'
|
||||
import enderDragon from '@/assets/instance-icons/ender-dragon.png'
|
||||
import engine from '@/assets/instance-icons/engine.png'
|
||||
import fabric from '@/assets/instance-icons/fabric.png'
|
||||
import forge from '@/assets/instance-icons/forge.png'
|
||||
import furnace from '@/assets/instance-icons/furnace.png'
|
||||
import gizmo from '@/assets/instance-icons/gizmo.png'
|
||||
import globe from '@/assets/instance-icons/globe.png'
|
||||
import grassBlock from '@/assets/instance-icons/grass-block.png'
|
||||
import lantern from '@/assets/instance-icons/lantern.png'
|
||||
import moobloom from '@/assets/instance-icons/moobloom.png'
|
||||
import mrPack from '@/assets/instance-icons/mr-pack.png'
|
||||
import neoForge from '@/assets/instance-icons/neoforge.png'
|
||||
import orb from '@/assets/instance-icons/orb.png'
|
||||
import oxygenDistributor from '@/assets/instance-icons/oxygen-distributor.png'
|
||||
import pancakes from '@/assets/instance-icons/pancakes.png'
|
||||
import pickaxe from '@/assets/instance-icons/pickaxe.png'
|
||||
import pokeBall from '@/assets/instance-icons/poke-ball.png'
|
||||
import quilt from '@/assets/instance-icons/quilt.png'
|
||||
import redstoneBlock from '@/assets/instance-icons/redstone-block.png'
|
||||
import sculkSensor from '@/assets/instance-icons/sculk-sensor.png'
|
||||
import skeleton from '@/assets/instance-icons/skeleton.png'
|
||||
import skillet from '@/assets/instance-icons/skillet.png'
|
||||
import slimeBlock from '@/assets/instance-icons/slime-block.png'
|
||||
import spaceHelmet from '@/assets/instance-icons/space-helmet.png'
|
||||
import stickyPiston from '@/assets/instance-icons/sticky-piston.png'
|
||||
import sword from '@/assets/instance-icons/sword.png'
|
||||
import terminal from '@/assets/instance-icons/terminal.png'
|
||||
import tinyPotato from '@/assets/instance-icons/tiny-potato.png'
|
||||
import tire from '@/assets/instance-icons/tire.png'
|
||||
import tnt from '@/assets/instance-icons/tnt.png'
|
||||
import wrench from '@/assets/instance-icons/wrench.png'
|
||||
import wrenchRinth from '@/assets/instance-icons/wrench-rinth.png'
|
||||
import zombie from '@/assets/instance-icons/zombie.png'
|
||||
|
||||
const names = defineMessages({
|
||||
yellow: {
|
||||
id: 'instance.icon-editor.background.yellow',
|
||||
defaultMessage: 'Yellow',
|
||||
},
|
||||
green: {
|
||||
id: 'instance.icon-editor.background.green',
|
||||
defaultMessage: 'Green',
|
||||
},
|
||||
lime: {
|
||||
id: 'instance.icon-editor.background.lime',
|
||||
defaultMessage: 'Lime',
|
||||
},
|
||||
darkGreen: {
|
||||
id: 'instance.icon-editor.background.dark-green',
|
||||
defaultMessage: 'Dark green',
|
||||
},
|
||||
purple: {
|
||||
id: 'instance.icon-editor.background.purple',
|
||||
defaultMessage: 'Purple',
|
||||
},
|
||||
blue: {
|
||||
id: 'instance.icon-editor.background.blue',
|
||||
defaultMessage: 'Blue',
|
||||
},
|
||||
orange: {
|
||||
id: 'instance.icon-editor.background.orange',
|
||||
defaultMessage: 'Orange',
|
||||
},
|
||||
red: {
|
||||
id: 'instance.icon-editor.background.red',
|
||||
defaultMessage: 'Red',
|
||||
},
|
||||
rose: {
|
||||
id: 'instance.icon-editor.background.rose',
|
||||
defaultMessage: 'Rose',
|
||||
},
|
||||
pink: {
|
||||
id: 'instance.icon-editor.background.pink',
|
||||
defaultMessage: 'Pink',
|
||||
},
|
||||
indigo: {
|
||||
id: 'instance.icon-editor.background.indigo',
|
||||
defaultMessage: 'Indigo',
|
||||
},
|
||||
lavender: {
|
||||
id: 'instance.icon-editor.background.lavender',
|
||||
defaultMessage: 'Lavender',
|
||||
},
|
||||
lightGray: {
|
||||
id: 'instance.icon-editor.background.light-gray',
|
||||
defaultMessage: 'Light gray',
|
||||
},
|
||||
gray: {
|
||||
id: 'instance.icon-editor.background.gray',
|
||||
defaultMessage: 'Gray',
|
||||
},
|
||||
darkGray: {
|
||||
id: 'instance.icon-editor.background.dark-gray',
|
||||
defaultMessage: 'Dark gray',
|
||||
},
|
||||
backpack: { id: 'instance.icon-editor.symbol.backpack', defaultMessage: 'Backpack' },
|
||||
beacon: { id: 'instance.icon-editor.symbol.beacon', defaultMessage: 'Beacon' },
|
||||
blueShark: { id: 'instance.icon-editor.symbol.blue-shark', defaultMessage: 'Blue Shark' },
|
||||
bookshelf: { id: 'instance.icon-editor.symbol.bookshelf', defaultMessage: 'Bookshelf' },
|
||||
brownBear: { id: 'instance.icon-editor.symbol.brown-bear', defaultMessage: 'Brown Bear' },
|
||||
cake: { id: 'instance.icon-editor.symbol.cake', defaultMessage: 'Cake' },
|
||||
campfire: { id: 'instance.icon-editor.symbol.campfire', defaultMessage: 'Campfire' },
|
||||
chest: { id: 'instance.icon-editor.symbol.chest', defaultMessage: 'Chest' },
|
||||
cogwheel: { id: 'instance.icon-editor.symbol.cogwheel', defaultMessage: 'Cogwheel' },
|
||||
commandBlock: {
|
||||
id: 'instance.icon-editor.symbol.command-block',
|
||||
defaultMessage: 'Command Block',
|
||||
},
|
||||
cookingPot: {
|
||||
id: 'instance.icon-editor.symbol.cooking-pot',
|
||||
defaultMessage: 'Cooking Pot',
|
||||
},
|
||||
couch: { id: 'instance.icon-editor.symbol.couch', defaultMessage: 'Couch' },
|
||||
craftingTable: {
|
||||
id: 'instance.icon-editor.symbol.crafting-table',
|
||||
defaultMessage: 'Crafting Table',
|
||||
},
|
||||
creeper: { id: 'instance.icon-editor.symbol.creeper', defaultMessage: 'Creeper' },
|
||||
enchantingTable: {
|
||||
id: 'instance.icon-editor.symbol.enchanting-table',
|
||||
defaultMessage: 'Enchanting Table',
|
||||
},
|
||||
enderChest: {
|
||||
id: 'instance.icon-editor.symbol.ender-chest',
|
||||
defaultMessage: 'Ender Chest',
|
||||
},
|
||||
enderDragon: {
|
||||
id: 'instance.icon-editor.symbol.ender-dragon',
|
||||
defaultMessage: 'Ender Dragon',
|
||||
},
|
||||
engine: { id: 'instance.icon-editor.symbol.engine', defaultMessage: 'Engine' },
|
||||
furnace: { id: 'instance.icon-editor.symbol.furnace', defaultMessage: 'Furnace' },
|
||||
gizmo: { id: 'instance.icon-editor.symbol.gizmo', defaultMessage: 'Gizmo' },
|
||||
globe: { id: 'instance.icon-editor.symbol.globe', defaultMessage: 'Globe' },
|
||||
grassBlock: {
|
||||
id: 'instance.icon-editor.symbol.grass-block',
|
||||
defaultMessage: 'Grass Block',
|
||||
},
|
||||
lantern: { id: 'instance.icon-editor.symbol.lantern', defaultMessage: 'Lantern' },
|
||||
moobloom: { id: 'instance.icon-editor.symbol.moobloom', defaultMessage: 'Moobloom' },
|
||||
mrPack: { id: 'instance.icon-editor.symbol.mr-pack', defaultMessage: 'Mr Pack' },
|
||||
orb: { id: 'instance.icon-editor.symbol.orb', defaultMessage: 'Orb' },
|
||||
oxygenDistributor: {
|
||||
id: 'instance.icon-editor.symbol.oxygen-distributor',
|
||||
defaultMessage: 'Oxygen Distributor',
|
||||
},
|
||||
pancakes: { id: 'instance.icon-editor.symbol.pancakes', defaultMessage: 'Pancakes' },
|
||||
pickaxe: { id: 'instance.icon-editor.symbol.pickaxe', defaultMessage: 'Pickaxe' },
|
||||
pokeBall: { id: 'instance.icon-editor.symbol.poke-ball', defaultMessage: 'Poke Ball' },
|
||||
redstoneBlock: {
|
||||
id: 'instance.icon-editor.symbol.redstone-block',
|
||||
defaultMessage: 'Redstone Block',
|
||||
},
|
||||
sculkSensor: {
|
||||
id: 'instance.icon-editor.symbol.sculk-sensor',
|
||||
defaultMessage: 'Sculk Sensor',
|
||||
},
|
||||
skeleton: { id: 'instance.icon-editor.symbol.skeleton', defaultMessage: 'Skeleton' },
|
||||
skillet: { id: 'instance.icon-editor.symbol.skillet', defaultMessage: 'Skillet' },
|
||||
slimeBlock: {
|
||||
id: 'instance.icon-editor.symbol.slime-block',
|
||||
defaultMessage: 'Slime Block',
|
||||
},
|
||||
spaceHelmet: {
|
||||
id: 'instance.icon-editor.symbol.space-helmet',
|
||||
defaultMessage: 'Space Helmet',
|
||||
},
|
||||
stickyPiston: {
|
||||
id: 'instance.icon-editor.symbol.sticky-piston',
|
||||
defaultMessage: 'Sticky Piston',
|
||||
},
|
||||
sword: { id: 'instance.icon-editor.symbol.sword', defaultMessage: 'Sword' },
|
||||
tnt: { id: 'instance.icon-editor.symbol.tnt', defaultMessage: 'TNT' },
|
||||
terminal: { id: 'instance.icon-editor.symbol.terminal', defaultMessage: 'Terminal' },
|
||||
tinyPotato: {
|
||||
id: 'instance.icon-editor.symbol.tiny-potato',
|
||||
defaultMessage: 'Tiny Potato',
|
||||
},
|
||||
tire: { id: 'instance.icon-editor.symbol.tire', defaultMessage: 'Tire' },
|
||||
wrench: { id: 'instance.icon-editor.symbol.create-wrench', defaultMessage: 'Wrench' },
|
||||
wrenchRinth: {
|
||||
id: 'instance.icon-editor.symbol.wrenth-rinth',
|
||||
defaultMessage: 'Modrinth Wrench',
|
||||
},
|
||||
zombie: { id: 'instance.icon-editor.symbol.zombie', defaultMessage: 'Zombie' },
|
||||
fabric: { id: 'instance.icon-editor.symbol.fabric', defaultMessage: 'Fabric' },
|
||||
forge: { id: 'instance.icon-editor.symbol.forge', defaultMessage: 'Forge' },
|
||||
neoForge: { id: 'instance.icon-editor.symbol.neoforge', defaultMessage: 'NeoForge' },
|
||||
quilt: { id: 'instance.icon-editor.symbol.quilt', defaultMessage: 'Quilt' },
|
||||
})
|
||||
|
||||
export interface SymbolOption {
|
||||
id: string
|
||||
name: MessageDescriptor
|
||||
asset: string
|
||||
category: 'loader' | 'modded' | 'vanilla'
|
||||
excludeFromRandomization?: boolean
|
||||
}
|
||||
|
||||
export const backgroundOptions = [
|
||||
{
|
||||
id: 'rose',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#D62E63',
|
||||
bottom_color: '#F95C62',
|
||||
},
|
||||
name: names.rose,
|
||||
},
|
||||
{
|
||||
id: 'orange',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#FF8D29',
|
||||
bottom_color: '#FFB452',
|
||||
},
|
||||
name: names.orange,
|
||||
},
|
||||
{
|
||||
id: 'yellow',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#FFC629',
|
||||
bottom_color: '#FFEE53',
|
||||
},
|
||||
name: names.yellow,
|
||||
},
|
||||
{
|
||||
id: 'lime',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#6FDA1D',
|
||||
bottom_color: '#CBFF50',
|
||||
},
|
||||
name: names.lime,
|
||||
},
|
||||
{
|
||||
id: 'green',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#0B9F21',
|
||||
bottom_color: '#4FD24B',
|
||||
},
|
||||
name: names.green,
|
||||
},
|
||||
// {
|
||||
// id: 'dark_green',
|
||||
// background: {
|
||||
// type: 'linear-top-down-gradient',
|
||||
// top_color: '#084C13',
|
||||
// bottom_color: '#327735',
|
||||
// },
|
||||
// name: names.darkGreen,
|
||||
// },
|
||||
// {
|
||||
// id: 'indigo',
|
||||
// background: {
|
||||
// type: 'linear-top-down-gradient',
|
||||
// top_color: '#3F00D3',
|
||||
// bottom_color: '#2659FE',
|
||||
// },
|
||||
// name: names.indigo,
|
||||
// },
|
||||
{
|
||||
id: 'purple',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#4739FF',
|
||||
bottom_color: '#6670FF',
|
||||
},
|
||||
name: names.purple,
|
||||
},
|
||||
{
|
||||
id: 'blue',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#227EFF',
|
||||
bottom_color: '#5EC1FF',
|
||||
},
|
||||
name: names.blue,
|
||||
},
|
||||
{
|
||||
id: 'lavender',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#C056FD',
|
||||
bottom_color: '#B889FF',
|
||||
},
|
||||
name: names.lavender,
|
||||
},
|
||||
{
|
||||
id: 'pink',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#F640C0',
|
||||
bottom_color: '#FF7BF1',
|
||||
},
|
||||
name: names.pink,
|
||||
},
|
||||
|
||||
// {
|
||||
// id: 'red',
|
||||
// background: {
|
||||
// type: 'linear-top-down-gradient',
|
||||
// top_color: '#F6111C',
|
||||
// bottom_color: '#F94548',
|
||||
// },
|
||||
// name: names.red,
|
||||
// },
|
||||
{
|
||||
id: 'light_gray',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#AEAEAE',
|
||||
bottom_color: '#D9D9D9',
|
||||
},
|
||||
name: names.lightGray,
|
||||
},
|
||||
{
|
||||
id: 'gray',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#373C4C',
|
||||
bottom_color: '#4C4F58',
|
||||
},
|
||||
name: names.gray,
|
||||
},
|
||||
{
|
||||
id: 'dark_gray',
|
||||
background: {
|
||||
type: 'linear-top-down-gradient',
|
||||
top_color: '#1B1D29',
|
||||
bottom_color: '#252731',
|
||||
},
|
||||
name: names.darkGray,
|
||||
},
|
||||
] as const
|
||||
|
||||
export const symbolOptions = [
|
||||
/////////////////////////
|
||||
// loaders
|
||||
/////////////////////////
|
||||
|
||||
{
|
||||
id: 'fabric',
|
||||
name: names.fabric,
|
||||
asset: fabric,
|
||||
category: 'loader',
|
||||
excludeFromRandomization: true,
|
||||
},
|
||||
{
|
||||
id: 'forge',
|
||||
name: names.forge,
|
||||
asset: forge,
|
||||
category: 'loader',
|
||||
excludeFromRandomization: true,
|
||||
},
|
||||
{
|
||||
id: 'neoforge',
|
||||
name: names.neoForge,
|
||||
asset: neoForge,
|
||||
category: 'loader',
|
||||
excludeFromRandomization: true,
|
||||
},
|
||||
{
|
||||
id: 'quilt',
|
||||
name: names.quilt,
|
||||
asset: quilt,
|
||||
category: 'loader',
|
||||
excludeFromRandomization: true,
|
||||
},
|
||||
|
||||
// Cobblemon: Poké Ball
|
||||
{ id: 'poke_ball', name: names.pokeBall, asset: pokeBall, category: 'modded' },
|
||||
|
||||
// Origins: Orb of Origins
|
||||
{ id: 'orb', name: names.orb, asset: orb, category: 'modded' },
|
||||
|
||||
// Farmer's Delight: Cooking Pot, Skillet
|
||||
{ id: 'cooking_pot', name: names.cookingPot, asset: cookingPot, category: 'modded' },
|
||||
{ id: 'skillet', name: names.skillet, asset: skillet, category: 'modded' },
|
||||
|
||||
// Supplementaries: Globe, Pancakes
|
||||
{ id: 'globe', name: names.globe, asset: globe, category: 'modded' },
|
||||
{ id: 'pancakes', name: names.pancakes, asset: pancakes, category: 'modded' },
|
||||
|
||||
// Sophisticated Backpacks: Backpack
|
||||
{ id: 'backpack', name: names.backpack, asset: backpack, category: 'modded' },
|
||||
|
||||
// Chipped: Chair
|
||||
{ id: 'couch', name: names.couch, asset: couch, category: 'modded' },
|
||||
|
||||
// Botania: Tiny Potato
|
||||
{ id: 'tiny_potato', name: names.tinyPotato, asset: tinyPotato, category: 'modded' },
|
||||
|
||||
// Blåhaj: Blue Shark
|
||||
{ id: 'blue_shark', name: names.blueShark, asset: blueShark, category: 'modded' },
|
||||
|
||||
// Other modded symbols: Brown Bear, Moobloom
|
||||
{ id: 'brown_bear', name: names.brownBear, asset: brownBear, category: 'modded' },
|
||||
{ id: 'moobloom', name: names.moobloom, asset: moobloom, category: 'modded' },
|
||||
|
||||
// Create: Wrench, Cogwheel
|
||||
{ id: 'create_wrench', name: names.wrench, asset: wrench, category: 'modded' },
|
||||
{ id: 'cogwheel', name: names.cogwheel, asset: cogwheel, category: 'modded' },
|
||||
|
||||
// Create Aeronautics: Engine, Tire
|
||||
{ id: 'engine', name: names.engine, asset: engine, category: 'modded' },
|
||||
{ id: 'tire', name: names.tire, asset: tire, category: 'modded' },
|
||||
|
||||
// Ad Astra: Oxygen Distributor, Space Helmet
|
||||
{
|
||||
id: 'oxygen_distributor',
|
||||
name: names.oxygenDistributor,
|
||||
asset: oxygenDistributor,
|
||||
category: 'modded',
|
||||
},
|
||||
{ id: 'space_helmet', name: names.spaceHelmet, asset: spaceHelmet, category: 'modded' },
|
||||
|
||||
// Miscellaneous: Gizmo, Terminal
|
||||
{ id: 'gizmo', name: names.gizmo, asset: gizmo, category: 'modded' },
|
||||
{ id: 'terminal', name: names.terminal, asset: terminal, category: 'modded' },
|
||||
|
||||
// Miscellaneous: Modrinth Wrench, Mr Pack
|
||||
{ id: 'wrenth_rinth', name: names.wrenchRinth, asset: wrenchRinth, category: 'modded' },
|
||||
{ id: 'mr_pack', name: names.mrPack, asset: mrPack, category: 'modded' },
|
||||
|
||||
/////////////////////////
|
||||
// vanilla ones
|
||||
/////////////////////////
|
||||
|
||||
{ id: 'grass_block', name: names.grassBlock, asset: grassBlock, category: 'vanilla' },
|
||||
|
||||
{ id: 'crafting_table', name: names.craftingTable, asset: craftingTable, category: 'vanilla' },
|
||||
{ id: 'furnace', name: names.furnace, asset: furnace, category: 'vanilla' },
|
||||
{ id: 'chest', name: names.chest, asset: chest, category: 'vanilla' },
|
||||
|
||||
{ id: 'bookshelf', name: names.bookshelf, asset: bookshelf, category: 'vanilla' },
|
||||
{
|
||||
id: 'redstone_block',
|
||||
name: names.redstoneBlock,
|
||||
asset: redstoneBlock,
|
||||
category: 'vanilla',
|
||||
},
|
||||
{
|
||||
id: 'sticky_piston',
|
||||
name: names.stickyPiston,
|
||||
asset: stickyPiston,
|
||||
category: 'vanilla',
|
||||
},
|
||||
{ id: 'slime_block', name: names.slimeBlock, asset: slimeBlock, category: 'vanilla' },
|
||||
{ id: 'cake', name: names.cake, asset: cake, category: 'vanilla' },
|
||||
{ id: 'campfire', name: names.campfire, asset: campfire, category: 'vanilla' },
|
||||
{ id: 'pickaxe', name: names.pickaxe, asset: pickaxe, category: 'vanilla' },
|
||||
{ id: 'sword', name: names.sword, asset: sword, category: 'vanilla' },
|
||||
{ id: 'zombie', name: names.zombie, asset: zombie, category: 'vanilla' },
|
||||
{ id: 'creeper', name: names.creeper, asset: creeper, category: 'vanilla' },
|
||||
{ id: 'skeleton', name: names.skeleton, asset: skeleton, category: 'vanilla' },
|
||||
{ id: 'ender_dragon', name: names.enderDragon, asset: enderDragon, category: 'vanilla' },
|
||||
{ id: 'ender_chest', name: names.enderChest, asset: enderChest, category: 'vanilla' },
|
||||
{ id: 'sculk_sensor', name: names.sculkSensor, asset: sculkSensor, category: 'vanilla' },
|
||||
{ id: 'beacon', name: names.beacon, asset: beacon, category: 'vanilla' },
|
||||
{
|
||||
id: 'enchanting_table',
|
||||
name: names.enchantingTable,
|
||||
asset: enchantingTable,
|
||||
category: 'vanilla',
|
||||
},
|
||||
{ id: 'lantern', name: names.lantern, asset: lantern, category: 'vanilla' },
|
||||
{ id: 'tnt', name: names.tnt, asset: tnt, category: 'vanilla' },
|
||||
{ id: 'command_block', name: names.commandBlock, asset: commandBlock, category: 'vanilla' },
|
||||
] as const satisfies readonly SymbolOption[]
|
||||
|
||||
export type BackgroundId = (typeof backgroundOptions)[number]['id']
|
||||
export type SymbolId = (typeof symbolOptions)[number]['id']
|
||||
|
||||
export const RANDOM_CONFIG_BLACKLIST = [
|
||||
{ background: 'purple', symbol: 'globe' },
|
||||
{ background: 'blue', symbol: 'globe' },
|
||||
{ background: 'gray', symbol: 'cogwheel' },
|
||||
{ background: 'dark_gray', symbol: 'cogwheel' },
|
||||
{ background: 'rose', symbol: 'poke_ball' },
|
||||
{ background: 'lime', symbol: 'slime_block' },
|
||||
{ background: 'green', symbol: 'slime_block' },
|
||||
{ background: 'rose', symbol: 'redstone_block' },
|
||||
{ background: 'rose', symbol: 'couch' },
|
||||
{ background: 'orange', symbol: 'space_helmet' },
|
||||
{ background: 'rose', symbol: 'tnt' },
|
||||
{ background: 'yellow', symbol: 'moobloom' },
|
||||
{ background: 'green', symbol: 'wrenth_rinth' },
|
||||
{ background: 'lime', symbol: 'mr_pack' },
|
||||
{ background: 'light_gray', symbol: 'skillet' },
|
||||
{ background: 'light_gray', symbol: 'cooking_pot' },
|
||||
] satisfies readonly { background: BackgroundId; symbol: SymbolId }[]
|
||||
|
||||
export const DEFAULT_BACKGROUND_ID = 'purple' satisfies BackgroundId
|
||||
export const DEFAULT_SYMBOL_ID = 'grass_block' satisfies SymbolId
|
||||
@@ -0,0 +1,540 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, InfoIcon, RefreshCwIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { toError } from '@/helpers/errors'
|
||||
import {
|
||||
cache_generated_icon,
|
||||
edit_generated_icon,
|
||||
edit_generated_icon_if_empty,
|
||||
get_recent_icon_configs,
|
||||
} from '@/helpers/instance'
|
||||
import type { IconBackground, InstanceIconConfig } from '@/helpers/types'
|
||||
|
||||
import {
|
||||
type BackgroundId,
|
||||
backgroundOptions,
|
||||
DEFAULT_BACKGROUND_ID,
|
||||
DEFAULT_SYMBOL_ID,
|
||||
RANDOM_CONFIG_BLACKLIST,
|
||||
type SymbolId,
|
||||
type SymbolOption,
|
||||
symbolOptions,
|
||||
} from './editor-catalog'
|
||||
|
||||
const props = defineProps<{
|
||||
instanceId?: string
|
||||
config?: InstanceIconConfig | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [iconPath: string, config: InstanceIconConfig]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const recentConfigs = ref<InstanceIconConfig[]>([])
|
||||
const saving = ref(false)
|
||||
const backgroundScroller = ref<HTMLElement | null>(null)
|
||||
const showLeftBackgroundShadow = ref(false)
|
||||
const showRightBackgroundShadow = ref(false)
|
||||
const draggingBackgrounds = ref(false)
|
||||
|
||||
let backgroundScrollerResizeObserver: ResizeObserver | null = null
|
||||
let backgroundDragPointerId: number | null = null
|
||||
let backgroundDragCaptureTarget: Element | null = null
|
||||
let backgroundDragStartX = 0
|
||||
let backgroundDragStartScrollLeft = 0
|
||||
let suppressBackgroundClick = false
|
||||
let suppressBackgroundClickTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const selectedBackground = ref<BackgroundId>(DEFAULT_BACKGROUND_ID)
|
||||
const selectedSymbol = ref<SymbolId>(DEFAULT_SYMBOL_ID)
|
||||
|
||||
const selectedBackgroundOption = computed(
|
||||
() => backgroundOptions.find((option) => option.id === selectedBackground.value)!,
|
||||
)
|
||||
const selectedSymbolOption = computed(
|
||||
() => symbolOptions.find((option) => option.id === selectedSymbol.value)!,
|
||||
)
|
||||
const vanillaSymbolStartIndex = symbolOptions.findIndex((option) => option.category === 'vanilla')
|
||||
const selectedConfig = computed<InstanceIconConfig>(() => ({
|
||||
background: { ...selectedBackgroundOption.value.background },
|
||||
symbol: selectedSymbol.value,
|
||||
}))
|
||||
const visibleRecentConfigs = computed(() =>
|
||||
recentConfigs.value.filter(
|
||||
(config) => backgroundOption(config.background) && symbolOption(config.symbol),
|
||||
),
|
||||
)
|
||||
|
||||
function updateBackgroundScrollShadows() {
|
||||
const el = backgroundScroller.value
|
||||
if (!el) {
|
||||
showLeftBackgroundShadow.value = false
|
||||
showRightBackgroundShadow.value = false
|
||||
return
|
||||
}
|
||||
|
||||
showLeftBackgroundShadow.value = el.scrollLeft > 0
|
||||
showRightBackgroundShadow.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
|
||||
}
|
||||
|
||||
function onBackgroundWheel(event: WheelEvent) {
|
||||
const el = backgroundScroller.value
|
||||
if (!el || el.scrollWidth <= el.clientWidth) return
|
||||
|
||||
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
|
||||
el.scrollLeft += delta
|
||||
}
|
||||
|
||||
function onBackgroundPointerDown(event: PointerEvent) {
|
||||
const el = backgroundScroller.value
|
||||
if (!el || event.pointerType === 'touch' || event.button !== 0) return
|
||||
|
||||
backgroundDragPointerId = event.pointerId
|
||||
backgroundDragStartX = event.clientX
|
||||
backgroundDragStartScrollLeft = el.scrollLeft
|
||||
suppressBackgroundClick = false
|
||||
backgroundDragCaptureTarget =
|
||||
event.target instanceof Element ? (event.target.closest('button') ?? el) : el
|
||||
backgroundDragCaptureTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function onBackgroundPointerMove(event: PointerEvent) {
|
||||
const el = backgroundScroller.value
|
||||
if (!el || event.pointerId !== backgroundDragPointerId) return
|
||||
|
||||
const distance = event.clientX - backgroundDragStartX
|
||||
if (!draggingBackgrounds.value && Math.abs(distance) < 4) return
|
||||
|
||||
draggingBackgrounds.value = true
|
||||
suppressBackgroundClick = true
|
||||
event.preventDefault()
|
||||
el.scrollLeft = backgroundDragStartScrollLeft - distance
|
||||
}
|
||||
|
||||
function finishBackgroundDrag(event: PointerEvent) {
|
||||
const el = backgroundScroller.value
|
||||
if (!el || event.pointerId !== backgroundDragPointerId) return
|
||||
|
||||
if (backgroundDragCaptureTarget?.hasPointerCapture(event.pointerId)) {
|
||||
backgroundDragCaptureTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
backgroundDragPointerId = null
|
||||
backgroundDragCaptureTarget = null
|
||||
draggingBackgrounds.value = false
|
||||
|
||||
if (suppressBackgroundClick) {
|
||||
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
|
||||
suppressBackgroundClickTimeout = setTimeout(() => {
|
||||
suppressBackgroundClick = false
|
||||
suppressBackgroundClickTimeout = null
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundClick(event: MouseEvent) {
|
||||
if (!suppressBackgroundClick) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressBackgroundClick = false
|
||||
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
|
||||
suppressBackgroundClickTimeout = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
backgroundScrollerResizeObserver = new ResizeObserver(updateBackgroundScrollShadows)
|
||||
if (backgroundScroller.value) backgroundScrollerResizeObserver.observe(backgroundScroller.value)
|
||||
nextTick(updateBackgroundScrollShadows)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
backgroundScrollerResizeObserver?.disconnect()
|
||||
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
|
||||
})
|
||||
|
||||
function backgroundOption(background?: IconBackground) {
|
||||
if (background?.type !== 'linear-top-down-gradient') return undefined
|
||||
return backgroundOptions.find(
|
||||
(option) =>
|
||||
option.background.top_color === background.top_color &&
|
||||
option.background.bottom_color === background.bottom_color,
|
||||
)
|
||||
}
|
||||
|
||||
function backgroundStyle(background: IconBackground) {
|
||||
if (background.type === 'color') return { backgroundColor: background.value }
|
||||
return {
|
||||
backgroundImage: `linear-gradient(to bottom, ${background.top_color}, ${background.bottom_color})`,
|
||||
}
|
||||
}
|
||||
|
||||
function backgroundKey(background: IconBackground) {
|
||||
if (background.type === 'color') return `${background.type}-${background.value}`
|
||||
return `${background.type}-${background.top_color}-${background.bottom_color}`
|
||||
}
|
||||
|
||||
function symbolOption(symbol: string) {
|
||||
return symbolOptions.find((option) => option.id === symbol)
|
||||
}
|
||||
|
||||
function selectRecent(config: InstanceIconConfig) {
|
||||
const background = backgroundOption(config.background)
|
||||
const symbol = symbolOption(config.symbol)
|
||||
if (!background || !symbol) return
|
||||
|
||||
selectedBackground.value = background.id
|
||||
selectedSymbol.value = symbol.id
|
||||
}
|
||||
|
||||
function surpriseMe() {
|
||||
const configurations = backgroundOptions.flatMap((background) =>
|
||||
symbolOptions
|
||||
.filter(
|
||||
(symbol: SymbolOption) =>
|
||||
!symbol.excludeFromRandomization &&
|
||||
background.id !== selectedBackground.value &&
|
||||
symbol.id !== selectedSymbol.value &&
|
||||
!RANDOM_CONFIG_BLACKLIST.some(
|
||||
(config) => config.background === background.id && config.symbol === symbol.id,
|
||||
),
|
||||
)
|
||||
.map((symbol) => ({ background: background.id, symbol: symbol.id })),
|
||||
)
|
||||
const configuration = configurations[Math.floor(Math.random() * configurations.length)]
|
||||
|
||||
if (!configuration) return
|
||||
selectedBackground.value = configuration.background
|
||||
selectedSymbol.value = configuration.symbol
|
||||
}
|
||||
|
||||
async function loadRecents() {
|
||||
try {
|
||||
recentConfigs.value = await get_recent_icon_configs()
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
selectedBackground.value = backgroundOption(props.config?.background)?.id ?? DEFAULT_BACKGROUND_ID
|
||||
selectedSymbol.value = symbolOption(props.config?.symbol ?? '')?.id ?? DEFAULT_SYMBOL_ID
|
||||
modal.value?.show()
|
||||
void loadRecents()
|
||||
nextTick(updateBackgroundScrollShadows)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
async function loadSymbolBytes(asset: string): Promise<number[]> {
|
||||
const response = await fetch(asset)
|
||||
if (!response.ok) throw new Error('Failed to load the icon symbol.')
|
||||
|
||||
return Array.from(new Uint8Array(await response.arrayBuffer()))
|
||||
}
|
||||
|
||||
async function saveIcon() {
|
||||
if (saving.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const config = selectedConfig.value
|
||||
const symbolBytes = await loadSymbolBytes(selectedSymbolOption.value.asset)
|
||||
const iconPath = props.instanceId
|
||||
? await edit_generated_icon(props.instanceId, config, symbolBytes)
|
||||
: await cache_generated_icon(config, symbolBytes, true)
|
||||
emit('saved', iconPath, config)
|
||||
saving.value = false
|
||||
await nextTick()
|
||||
hide()
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function randomizeAndSave() {
|
||||
try {
|
||||
surpriseMe()
|
||||
const config = selectedConfig.value
|
||||
const iconPath = await cache_generated_icon(
|
||||
config,
|
||||
await loadSymbolBytes(selectedSymbolOption.value.asset),
|
||||
)
|
||||
return { iconPath, config }
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function applyGeneratedIcon(instanceId: string, config: InstanceIconConfig) {
|
||||
try {
|
||||
const symbol = symbolOption(config.symbol)
|
||||
if (!backgroundOption(config.background) || !symbol) return false
|
||||
|
||||
await edit_generated_icon_if_empty(instanceId, config, await loadSymbolBytes(symbol.asset))
|
||||
return true
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show, hide, randomize: randomizeAndSave, randomizeAndSave, applyGeneratedIcon })
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'instance.icon-editor.title',
|
||||
defaultMessage: 'Icon editor',
|
||||
},
|
||||
background: {
|
||||
id: 'instance.icon-editor.background',
|
||||
defaultMessage: 'Background',
|
||||
},
|
||||
symbol: {
|
||||
id: 'instance.icon-editor.symbol',
|
||||
defaultMessage: 'Symbol',
|
||||
},
|
||||
surpriseMe: {
|
||||
id: 'instance.icon-editor.surprise-me',
|
||||
defaultMessage: 'Randomize',
|
||||
},
|
||||
recents: {
|
||||
id: 'instance.icon-editor.recents',
|
||||
defaultMessage: 'Recents',
|
||||
},
|
||||
description: {
|
||||
id: 'instance.icon-editor.description',
|
||||
defaultMessage: 'Mix and match elements to create a custom icon.',
|
||||
},
|
||||
saveIcon: {
|
||||
id: 'instance.icon-editor.save',
|
||||
defaultMessage: 'Save icon',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="928px"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
no-padding
|
||||
actions-divider
|
||||
:disable-close="saving"
|
||||
>
|
||||
<div class="flex h-[552px] max-h-[calc(100vh-168px)] min-h-0">
|
||||
<aside
|
||||
class="flex w-[244px] shrink-0 flex-col gap-4 overflow-y-auto border-0 border-r border-solid border-surface-5 p-6"
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-col items-center gap-3.5 rounded-[20px] border border-solid border-surface-4 bg-surface-2 p-5"
|
||||
>
|
||||
<Avatar
|
||||
:src="selectedSymbolOption.asset"
|
||||
size="132px"
|
||||
:style="backgroundStyle(selectedBackgroundOption.background)"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<Avatar
|
||||
:src="selectedSymbolOption.asset"
|
||||
size="40px"
|
||||
:style="backgroundStyle(selectedBackgroundOption.background)"
|
||||
no-shadow
|
||||
/>
|
||||
<Avatar
|
||||
:src="selectedSymbolOption.asset"
|
||||
size="30px"
|
||||
:style="backgroundStyle(selectedBackgroundOption.background)"
|
||||
no-shadow
|
||||
/>
|
||||
<Avatar
|
||||
:src="selectedSymbolOption.asset"
|
||||
size="20px"
|
||||
:style="backgroundStyle(selectedBackgroundOption.background)"
|
||||
no-shadow
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button class="w-full" @click="surpriseMe">
|
||||
<RefreshCwIcon />
|
||||
{{ formatMessage(messages.surpriseMe) }}
|
||||
</Button>
|
||||
|
||||
<div v-if="visibleRecentConfigs.length" class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.recents) }}</span>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="(recentConfig, index) in visibleRecentConfigs"
|
||||
:key="`${backgroundKey(recentConfig.background)}-${recentConfig.symbol}`"
|
||||
class="icon-outline relative size-10 cursor-pointer overflow-hidden rounded-xl border-0 p-0 transition-transform hover:scale-105"
|
||||
:style="backgroundStyle(recentConfig.background)"
|
||||
:aria-label="`${formatMessage(messages.recents)} ${index + 1}`"
|
||||
@click="selectRecent(recentConfig)"
|
||||
>
|
||||
<img
|
||||
:src="symbolOption(recentConfig.symbol)?.asset"
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-y-auto bg-surface-2">
|
||||
<section class="border-0 border-b border-solid border-surface-5 p-4">
|
||||
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.background) }}
|
||||
</h3>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="background-scroll-shadow-left pointer-events-none absolute bottom-0 -left-0.5 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
|
||||
:class="showLeftBackgroundShadow ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<div
|
||||
ref="backgroundScroller"
|
||||
class="flex w-full select-none gap-2.5 overflow-x-auto overflow-y-hidden pb-2 pr-6"
|
||||
:class="{ 'cursor-grabbing': draggingBackgrounds }"
|
||||
@pointerdown="onBackgroundPointerDown"
|
||||
@pointermove="onBackgroundPointerMove"
|
||||
@pointerup="finishBackgroundDrag"
|
||||
@pointercancel="finishBackgroundDrag"
|
||||
@click.capture="onBackgroundClick"
|
||||
@wheel.prevent="onBackgroundWheel"
|
||||
@scroll="updateBackgroundScrollShadows"
|
||||
>
|
||||
<button
|
||||
v-for="option in backgroundOptions"
|
||||
:key="option.id"
|
||||
class="icon-option icon-outline relative aspect-square w-[calc((100%_-_3.125rem)/6)] shrink-0 cursor-pointer rounded-[20px] border-0 p-0"
|
||||
:class="{ 'icon-outline-selected': selectedBackground === option.id }"
|
||||
:style="backgroundStyle(option.background)"
|
||||
:aria-label="formatMessage(option.name)"
|
||||
:aria-pressed="selectedBackground === option.id"
|
||||
@click="selectedBackground = option.id"
|
||||
>
|
||||
<span
|
||||
v-if="selectedBackground === option.id"
|
||||
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
|
||||
>
|
||||
<CheckIcon class="size-4" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="background-scroll-shadow-right pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
|
||||
:class="showRightBackgroundShadow ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="p-4">
|
||||
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.symbol) }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-6 gap-2.5">
|
||||
<template v-for="(option, index) in symbolOptions" :key="option.id">
|
||||
<hr
|
||||
v-if="index === vanillaSymbolStartIndex"
|
||||
class="col-span-6 my-2.5 mx-1 w-full border-0 border-t border-solid border-surface-5"
|
||||
/>
|
||||
<button
|
||||
v-tooltip="{
|
||||
content: formatMessage(option.name),
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="icon-option icon-outline relative aspect-square cursor-pointer overflow-hidden rounded-[20px] border-0 bg-transparent p-0"
|
||||
:class="{ 'icon-outline-selected': selectedSymbol === option.id }"
|
||||
:aria-label="formatMessage(option.name)"
|
||||
:aria-pressed="selectedSymbol === option.id"
|
||||
@click="selectedSymbol = option.id"
|
||||
>
|
||||
<img :src="option.asset" alt="" class="size-full object-cover" />
|
||||
<span
|
||||
v-if="selectedSymbol === option.id"
|
||||
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
|
||||
>
|
||||
<CheckIcon class="size-4" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-between gap-4 px-2">
|
||||
<div class="flex min-w-0 items-center gap-2 text-primary">
|
||||
<InfoIcon class="size-6 shrink-0 text-blue" />
|
||||
<span>{{ formatMessage(messages.description) }}</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button :disabled="saving" type="outlined" @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" :disabled="saving" @click="saveIcon">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
<SaveIcon v-else />
|
||||
{{ formatMessage(messages.saveIcon) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-outline {
|
||||
outline: 1px solid color-mix(in srgb, var(--color-text-primary) 15%, transparent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.icon-option:not(.icon-outline-selected):hover {
|
||||
outline-color: color-mix(in srgb, var(--color-text-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.icon-option {
|
||||
transition: outline-color 150ms ease;
|
||||
}
|
||||
|
||||
.cursor-grabbing,
|
||||
.cursor-grabbing * {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.icon-outline-selected {
|
||||
outline-color: color-mix(in srgb, var(--color-text-primary) 60%, transparent);
|
||||
}
|
||||
|
||||
.background-scroll-shadow-left {
|
||||
-webkit-mask-image: linear-gradient(to right, black, transparent);
|
||||
mask-image: linear-gradient(to right, black, transparent);
|
||||
}
|
||||
|
||||
.background-scroll-shadow-right {
|
||||
-webkit-mask-image: linear-gradient(to left, black, transparent);
|
||||
mask-image: linear-gradient(to left, black, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<FloatingActionBar
|
||||
:shown="selectedInstanceCount > 0"
|
||||
:aria-label="formatMessage(messages.ariaLabel)"
|
||||
hide-when-modal-open
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ formatMessage(messages.selectedCount, { count: selectedLibraryInstances.size }) }}
|
||||
</span>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<Button
|
||||
type="quiet"
|
||||
class="!text-primary"
|
||||
:disabled="busy"
|
||||
@click="clearLibraryInstanceSelection"
|
||||
>
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-0.5">
|
||||
<Button
|
||||
v-if="displayState.group === 'Group'"
|
||||
type="quiet"
|
||||
:disabled="busy"
|
||||
@click="createGroupFromSelection"
|
||||
>
|
||||
<SquarePlusIcon />
|
||||
<span class="bar-label">{{ formatMessage(messages.newGroup) }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedGroupedInstances.length > 0"
|
||||
type="quiet"
|
||||
:disabled="busy"
|
||||
@click="removeSelectedInstancesFromGroups"
|
||||
>
|
||||
<MinusIcon />
|
||||
<span class="bar-label">{{ formatMessage(messages.removeFromGroup) }}</span>
|
||||
</Button>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<Button
|
||||
v-tooltip="deleting ? formatMessage(messages.deleting) : undefined"
|
||||
type="quiet"
|
||||
color="red"
|
||||
interaction="filled"
|
||||
:disabled="busy"
|
||||
@click="confirmDeleteModal?.show()"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="confirmDeleteModal"
|
||||
:instances="selectedInstances"
|
||||
@delete="deleteSelectedInstances"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MinusIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FloatingActionBar,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { toError } from '@/helpers/errors'
|
||||
import { remove } from '@/helpers/instance'
|
||||
import { set_group_memberships as setInstanceGroupMemberships } from '@/helpers/instance-groups'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const {
|
||||
instances,
|
||||
selectedLibraryInstances,
|
||||
clearLibraryInstanceSelection,
|
||||
setSelectedLibraryInstances,
|
||||
creatingGroup,
|
||||
createDefaultGroup,
|
||||
displayState,
|
||||
} = useLibrary()
|
||||
|
||||
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
|
||||
const deleting = ref(false)
|
||||
const removingFromGroup = ref(false)
|
||||
const selectedInstanceCount = computed(() => selectedLibraryInstances.value.size)
|
||||
const selectedInstanceIds = computed(
|
||||
() =>
|
||||
new Set([...selectedLibraryInstances.value.values()].map((selection) => selection.instanceId)),
|
||||
)
|
||||
const selectedInstances = computed(() =>
|
||||
instances.value.filter((instance) => selectedInstanceIds.value.has(instance.id)),
|
||||
)
|
||||
const selectedGroupedInstances = computed(() =>
|
||||
[...selectedLibraryInstances.value.values()].flatMap((selection) => {
|
||||
const instance = instances.value.find((candidate) => candidate.id === selection.instanceId)
|
||||
return instance?.group_ids.includes(selection.groupId) ? [{ instance, selection }] : []
|
||||
}),
|
||||
)
|
||||
const busy = computed(() => deleting.value || removingFromGroup.value || creatingGroup.value)
|
||||
|
||||
const messages = defineMessages({
|
||||
ariaLabel: {
|
||||
id: 'app.library.selection.aria-label',
|
||||
defaultMessage: 'Selected instances',
|
||||
},
|
||||
selectedCount: {
|
||||
id: 'app.library.selection.selected-count',
|
||||
defaultMessage: '{count} selected',
|
||||
},
|
||||
deleting: {
|
||||
id: 'app.library.selection.deleting',
|
||||
defaultMessage: 'Deleting selected instances',
|
||||
},
|
||||
newGroup: {
|
||||
id: 'app.library.selection.new-group',
|
||||
defaultMessage: 'New group',
|
||||
},
|
||||
removeFromGroup: {
|
||||
id: 'app.library.selection.remove-from-group',
|
||||
defaultMessage: 'Remove from group',
|
||||
},
|
||||
})
|
||||
|
||||
async function createGroupFromSelection() {
|
||||
if (busy.value) return
|
||||
|
||||
const instanceIds = selectedInstanceIds.value
|
||||
clearLibraryInstanceSelection()
|
||||
await createDefaultGroup(instanceIds)
|
||||
}
|
||||
|
||||
async function removeSelectedInstancesFromGroups() {
|
||||
if (busy.value || selectedGroupedInstances.value.length === 0) return
|
||||
|
||||
removingFromGroup.value = true
|
||||
const selectedGroupIdsByInstanceId = new Map<string, Set<string>>()
|
||||
for (const { instance, selection } of selectedGroupedInstances.value) {
|
||||
const groupIds = selectedGroupIdsByInstanceId.get(instance.id) ?? new Set()
|
||||
groupIds.add(selection.groupId)
|
||||
selectedGroupIdsByInstanceId.set(instance.id, groupIds)
|
||||
}
|
||||
const operations = [...selectedGroupIdsByInstanceId].flatMap(([instanceId, groupIds]) => {
|
||||
const instance = instances.value.find((candidate) => candidate.id === instanceId)
|
||||
return instance ? [{ instance, groupIds }] : []
|
||||
})
|
||||
try {
|
||||
await setInstanceGroupMemberships(
|
||||
operations.map(({ instance, groupIds }) => ({
|
||||
instance_id: instance.id,
|
||||
group_ids: instance.group_ids.filter((groupId) => !groupIds.has(groupId)),
|
||||
})),
|
||||
)
|
||||
|
||||
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
|
||||
for (const { instance, groupIds } of operations) {
|
||||
for (const groupId of groupIds) {
|
||||
nextSelectedInstances.delete(
|
||||
getLibraryInstanceSelectionKey({
|
||||
instanceId: instance.id,
|
||||
groupId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedLibraryInstances(nextSelectedInstances.values())
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
} finally {
|
||||
removingFromGroup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedInstances() {
|
||||
if (busy.value || selectedInstanceCount.value === 0) return
|
||||
|
||||
deleting.value = true
|
||||
const instanceIds = [...selectedInstanceIds.value]
|
||||
const results = await Promise.allSettled(instanceIds.map((instanceId) => remove(instanceId)))
|
||||
const deletedInstanceIds = new Set<string>()
|
||||
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === 'rejected') {
|
||||
handleError(toError(result.reason))
|
||||
} else {
|
||||
deletedInstanceIds.add(instanceIds[index])
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedLibraryInstances(
|
||||
[...selectedLibraryInstances.value.values()].filter(
|
||||
(selection) => !deletedInstanceIds.has(selection.instanceId),
|
||||
),
|
||||
)
|
||||
deleting.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
no-padding
|
||||
scrollable
|
||||
actions-divider
|
||||
max-width="560px"
|
||||
width="560px"
|
||||
:on-hide="closeGroupInstancesModal"
|
||||
>
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{
|
||||
formatMessage(messages.title, {
|
||||
groupName:
|
||||
groupInstancesModalGroup?.id === 'group:none'
|
||||
? formatMessage(messages.ungrouped)
|
||||
: (groupInstancesModalGroup?.name ?? ''),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
|
||||
<div class="px-6">
|
||||
<Input
|
||||
v-model="groupInstancesSearch"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="groupInstances.length === 0"
|
||||
class="flex items-center justify-center py-12 text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noInstancesFound) }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="instance in groupInstances"
|
||||
:key="instance.id"
|
||||
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
|
||||
:class="{ 'opacity-60': selectedGroupInstanceIds.has(instance.id) }"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
:tint-by="instance.id"
|
||||
:alt="instance.name"
|
||||
size="2rem"
|
||||
rounded="md"
|
||||
/>
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
</div>
|
||||
<Button
|
||||
:type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
|
||||
:disabled="
|
||||
groupInstancesModalGroup?.id === 'group:none' &&
|
||||
selectedGroupInstanceIds.has(instance.id)
|
||||
"
|
||||
@click="toggleGroupInstance(instance.id)"
|
||||
>
|
||||
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
|
||||
{{
|
||||
formatMessage(
|
||||
selectedGroupInstanceIds.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(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" :disabled="savingGroupInstances" @click="save">
|
||||
<SpinnerIcon v-if="savingGroupInstances" class="animate-spin" />
|
||||
<CheckIcon v-else />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
Input,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
import { getInstanceIconUrl } from '@/helpers/instance'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
ungrouped: {
|
||||
id: 'app.library.group.ungrouped',
|
||||
defaultMessage: 'Ungrouped',
|
||||
},
|
||||
title: {
|
||||
id: 'app.library.group.instances-modal.title',
|
||||
defaultMessage: 'Add instances to "{groupName}"',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'app.library.group.instances-modal.search-placeholder',
|
||||
defaultMessage: 'Search instance',
|
||||
},
|
||||
noInstancesFound: {
|
||||
id: 'app.library.group.instances-modal.no-instances-found',
|
||||
defaultMessage: 'No instances found',
|
||||
},
|
||||
add: {
|
||||
id: 'app.library.group.instances-modal.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
added: {
|
||||
id: 'app.library.group.instances-modal.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
isGroupInstancesModalOpen,
|
||||
groupInstancesModalGroup,
|
||||
groupInstancesSearch,
|
||||
groupInstances,
|
||||
selectedGroupInstanceIds,
|
||||
savingGroupInstances,
|
||||
closeGroupInstancesModal,
|
||||
toggleGroupInstance,
|
||||
saveGroupInstances,
|
||||
} = useLibrary()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
watch(isGroupInstancesModalOpen, (open) => {
|
||||
if (open) {
|
||||
modal.value?.show()
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (await saveGroupInstances()) {
|
||||
modal.value?.hide()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,483 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
EditIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
MinusIcon,
|
||||
PaletteIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
|
||||
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
|
||||
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
|
||||
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
|
||||
import LibraryToolbar from '@/components/ui/library/library-toolbar/index.vue'
|
||||
import LibrarySelectionActionBar from '@/components/ui/library/LibrarySelectionActionBar.vue'
|
||||
import {
|
||||
getLibraryInstanceSelectionKey,
|
||||
type InstanceGroup as InstanceGroupType,
|
||||
provideLibrary,
|
||||
} from '@/components/ui/library/use-library'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { FAVORITES_GROUP_ID } from '@/helpers/instance-groups'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
library: { id: 'app.library.title', defaultMessage: 'Library' },
|
||||
noSearchResults: {
|
||||
id: 'app.library.search.no-results.title',
|
||||
defaultMessage: 'No instances match your search.',
|
||||
},
|
||||
play: { id: 'app.library.instance.action.play', defaultMessage: 'Play' },
|
||||
stop: { id: 'app.library.instance.action.stop', defaultMessage: 'Stop' },
|
||||
addToFavorites: {
|
||||
id: 'app.library.instance.action.add-to-favorites',
|
||||
defaultMessage: 'Add to favorites',
|
||||
},
|
||||
removeFromFavorites: {
|
||||
id: 'app.library.instance.action.remove-from-favorites',
|
||||
defaultMessage: 'Remove from favorites',
|
||||
},
|
||||
addContent: { id: 'app.library.instance.action.add-content', defaultMessage: 'Add content' },
|
||||
viewInstance: {
|
||||
id: 'app.library.instance.action.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
editIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.select',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
replaceIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.replace',
|
||||
defaultMessage: 'Replace icon',
|
||||
},
|
||||
createIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.create',
|
||||
defaultMessage: 'Create an icon',
|
||||
},
|
||||
editCreatedIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.edit-created',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.remove',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
duplicateInstance: {
|
||||
id: 'app.library.instance.action.duplicate',
|
||||
defaultMessage: 'Duplicate instance',
|
||||
},
|
||||
delete: { id: 'app.library.instance.action.delete', defaultMessage: 'Delete' },
|
||||
openFolder: { id: 'app.library.instance.action.open-folder', defaultMessage: 'Open folder' },
|
||||
copyPath: { id: 'app.library.instance.action.copy-path', defaultMessage: 'Copy path' },
|
||||
removeFromGroup: {
|
||||
id: 'app.library.instance.action.remove-from-group',
|
||||
defaultMessage: 'Remove from group',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
instanceGroups,
|
||||
libraryGroupsLoaded,
|
||||
isSearching,
|
||||
displayState,
|
||||
filters,
|
||||
reorderingGroups,
|
||||
reorderGroups,
|
||||
instanceOptions,
|
||||
confirmDeleteModal,
|
||||
iconEditorModal,
|
||||
currentIconEditorInstance,
|
||||
currentDeleteInstances,
|
||||
clearLibraryInstanceSelection,
|
||||
deleteInstance,
|
||||
handleInstanceOption,
|
||||
handleInstanceIconSaved,
|
||||
selectedLibraryInstances,
|
||||
setSelectedLibraryInstances,
|
||||
toggleLibraryInstanceSelection,
|
||||
} = provideLibrary(toRef(props, 'instances'))
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
Object.values(filters.value).some((selectedValues) => selectedValues.length > 0),
|
||||
)
|
||||
|
||||
const visibleInstanceGroups = computed(() =>
|
||||
instanceGroups.value.filter((instanceGroup) =>
|
||||
instanceGroup.id === FAVORITES_GROUP_ID
|
||||
? instanceGroup.instances.length > 0
|
||||
: instanceGroup.instances.length > 0 ||
|
||||
(!isSearching.value && !hasActiveFilters.value && instanceGroup.key !== 'None'),
|
||||
),
|
||||
)
|
||||
|
||||
const visibleReorderableGroups = computed(() =>
|
||||
displayState.value.group === 'Group'
|
||||
? visibleInstanceGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID)
|
||||
: [],
|
||||
)
|
||||
const visibleFavoritesGroup = computed(() =>
|
||||
visibleInstanceGroups.value.find((group) => group.id === FAVORITES_GROUP_ID),
|
||||
)
|
||||
const draggableGroups = ref<InstanceGroupType[]>([])
|
||||
const libraryGroupsContainer = ref<HTMLElement>()
|
||||
const isDraggingGroup = ref(false)
|
||||
const GROUP_REORDERING_CLASS = 'instance-group-reordering'
|
||||
const canDragReorderGroups = computed(
|
||||
() => !reorderingGroups.value && draggableGroups.value.length > 1,
|
||||
)
|
||||
|
||||
watch(
|
||||
visibleReorderableGroups,
|
||||
(groups) => {
|
||||
if (!isDraggingGroup.value) {
|
||||
const previousGroupTops = getReorderableGroupTops()
|
||||
draggableGroups.value = [...groups]
|
||||
void nextTick(() => animateGroupReorder(previousGroupTops))
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function getReorderableGroupTops() {
|
||||
const groupTops = new Map<string, number>()
|
||||
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
|
||||
'[data-instance-group-reorder-id]',
|
||||
)
|
||||
|
||||
for (const groupElement of groupElements ?? []) {
|
||||
const groupId = groupElement.dataset.instanceGroupReorderId
|
||||
if (groupId) {
|
||||
groupTops.set(groupId, groupElement.getBoundingClientRect().top)
|
||||
}
|
||||
}
|
||||
|
||||
return groupTops
|
||||
}
|
||||
|
||||
function animateGroupReorder(previousGroupTops: Map<string, number>) {
|
||||
if (
|
||||
previousGroupTops.size === 0 ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
|
||||
'[data-instance-group-reorder-id]',
|
||||
)
|
||||
|
||||
for (const groupElement of groupElements ?? []) {
|
||||
const groupId = groupElement.dataset.instanceGroupReorderId
|
||||
const previousTop = groupId ? previousGroupTops.get(groupId) : undefined
|
||||
if (previousTop === undefined) continue
|
||||
|
||||
const offset = previousTop - groupElement.getBoundingClientRect().top
|
||||
if (Math.abs(offset) < 1) continue
|
||||
|
||||
groupElement.animate(
|
||||
[{ transform: `translateY(${offset}px)` }, { transform: 'translateY(0)' }],
|
||||
{ duration: 200, easing: 'ease-out' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function onGroupDragStart() {
|
||||
isDraggingGroup.value = true
|
||||
document.documentElement.classList.add(GROUP_REORDERING_CLASS)
|
||||
}
|
||||
|
||||
function onGroupDragEnd() {
|
||||
isDraggingGroup.value = false
|
||||
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
|
||||
|
||||
const currentGroupIds = visibleReorderableGroups.value.map((group) => group.id)
|
||||
const orderedGroupIds = draggableGroups.value.map((group) => group.id)
|
||||
if (orderedGroupIds.every((groupId, index) => groupId === currentGroupIds[index])) {
|
||||
draggableGroups.value = [...visibleReorderableGroups.value]
|
||||
return
|
||||
}
|
||||
|
||||
void reorderGroups(orderedGroupIds)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
|
||||
})
|
||||
|
||||
onDeactivated(clearLibraryInstanceSelection)
|
||||
|
||||
const anchorInstance = ref<{ groupId: string; instanceId: string } | null>(null)
|
||||
|
||||
function handleToggleInstance(groupId: string, instanceId: string, shiftKey: boolean) {
|
||||
const displayedInstances = visibleInstanceGroups.value.flatMap((group) =>
|
||||
group.instances.map((instance) => ({
|
||||
groupId: group.id,
|
||||
instanceId: instance.id,
|
||||
})),
|
||||
)
|
||||
const anchor = anchorInstance.value
|
||||
|
||||
if (shiftKey && anchor && displayedInstances.length) {
|
||||
const anchorIndex = displayedInstances.findIndex(
|
||||
(instance) =>
|
||||
instance.groupId === anchor.groupId && instance.instanceId === anchor.instanceId,
|
||||
)
|
||||
const targetIndex = displayedInstances.findIndex(
|
||||
(instance) => instance.groupId === groupId && instance.instanceId === instanceId,
|
||||
)
|
||||
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
toggleLibraryInstanceSelection({ groupId, instanceId })
|
||||
return
|
||||
}
|
||||
|
||||
const start = Math.min(anchorIndex, targetIndex)
|
||||
const end = Math.max(anchorIndex, targetIndex)
|
||||
const range = displayedInstances.slice(start, end + 1)
|
||||
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
|
||||
const targetKey = getLibraryInstanceSelectionKey({ groupId, instanceId })
|
||||
|
||||
if (nextSelectedInstances.has(targetKey)) {
|
||||
for (const instance of range) {
|
||||
nextSelectedInstances.delete(getLibraryInstanceSelectionKey(instance))
|
||||
}
|
||||
} else {
|
||||
for (const instance of range) {
|
||||
nextSelectedInstances.set(getLibraryInstanceSelectionKey(instance), instance)
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedLibraryInstances(nextSelectedInstances.values())
|
||||
anchorInstance.value = null
|
||||
return
|
||||
}
|
||||
|
||||
toggleLibraryInstanceSelection({ groupId, instanceId })
|
||||
anchorInstance.value = { groupId, instanceId }
|
||||
}
|
||||
|
||||
function setInstanceOptions(component: unknown) {
|
||||
instanceOptions.value = component as InstanceType<typeof ContextMenu> | null
|
||||
}
|
||||
|
||||
function setConfirmDeleteModal(component: unknown) {
|
||||
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
|
||||
}
|
||||
|
||||
function setIconEditorModal(component: unknown) {
|
||||
iconEditorModal.value = component as InstanceType<typeof IconEditorModal> | null
|
||||
}
|
||||
|
||||
watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
if (selectedInstances.size === 0) {
|
||||
anchorInstance.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
anchorInstance.value &&
|
||||
!selectedInstances.has(getLibraryInstanceSelectionKey(anchorInstance.value))
|
||||
) {
|
||||
anchorInstance.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InstanceGroupDnd :instances="instances">
|
||||
<section data-library-page-background class="flex flex-col gap-3 pb-16 min-h-[500px]">
|
||||
<h2 class="m-0 text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.library) }}
|
||||
</h2>
|
||||
<LibraryToolbar />
|
||||
<div
|
||||
v-if="libraryGroupsLoaded && isSearching && visibleInstanceGroups.length === 0"
|
||||
class="text-base text-primary"
|
||||
>
|
||||
{{ formatMessage(messages.noSearchResults) }}
|
||||
</div>
|
||||
<Transition
|
||||
v-else
|
||||
enter-active-class="transition-opacity duration-200 ease-out motion-reduce:transition-none"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
>
|
||||
<div
|
||||
v-if="libraryGroupsLoaded && displayState.group === 'Group'"
|
||||
ref="libraryGroupsContainer"
|
||||
data-library-page-background
|
||||
class="flex flex-col"
|
||||
>
|
||||
<div v-if="visibleFavoritesGroup" class="min-w-0">
|
||||
<InstanceGroup
|
||||
:instance-group="visibleFavoritesGroup"
|
||||
:selection-anchor-instance-id="
|
||||
anchorInstance?.groupId === FAVORITES_GROUP_ID ? anchorInstance.instanceId : null
|
||||
"
|
||||
@toggle-selection="
|
||||
(instanceId: string, shiftKey: boolean) =>
|
||||
handleToggleInstance(FAVORITES_GROUP_ID, instanceId, shiftKey)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Draggable
|
||||
:list="draggableGroups"
|
||||
class="flex flex-col"
|
||||
item-key="id"
|
||||
:disabled="!canDragReorderGroups"
|
||||
:animation="250"
|
||||
:swap-threshold="0.75"
|
||||
:invert-swap="true"
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="true"
|
||||
:fallback-tolerance="4"
|
||||
filter=".instance-group-reorder-ignore, input, textarea, [contenteditable='true']"
|
||||
handle=".instance-group-reorder-handle"
|
||||
:prevent-on-filter="false"
|
||||
ghost-class="instance-group-reorder-ghost"
|
||||
chosen-class="instance-group-reorder-chosen"
|
||||
drag-class="instance-group-reorder-drag"
|
||||
fallback-class="instance-group-reorder-fallback"
|
||||
@start="onGroupDragStart"
|
||||
@end="onGroupDragEnd"
|
||||
>
|
||||
<template #item="{ element: instanceGroup }">
|
||||
<div
|
||||
:key="instanceGroup.id"
|
||||
class="min-w-0 w-full"
|
||||
:data-instance-group-reorder-id="instanceGroup.id"
|
||||
>
|
||||
<InstanceGroup
|
||||
:can-drag-reorder="canDragReorderGroups"
|
||||
:hide-header="
|
||||
instanceGroup.id === 'group:none' && visibleInstanceGroups.length === 1
|
||||
"
|
||||
:instance-group="instanceGroup"
|
||||
:selection-anchor-instance-id="
|
||||
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
|
||||
"
|
||||
@toggle-selection="
|
||||
(instanceId: string, shiftKey: boolean) =>
|
||||
handleToggleInstance(instanceGroup.id, instanceId, shiftKey)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
v-else-if="libraryGroupsLoaded"
|
||||
data-library-page-background
|
||||
tag="div"
|
||||
class="flex flex-col"
|
||||
move-class="transition-transform duration-200 ease-out"
|
||||
enter-active-class="transition-[opacity,transform] duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<div
|
||||
v-for="instanceGroup in visibleInstanceGroups"
|
||||
:key="instanceGroup.id"
|
||||
class="min-w-0"
|
||||
>
|
||||
<InstanceGroup
|
||||
:hide-header="instanceGroup.key === 'None' && visibleInstanceGroups.length === 1"
|
||||
:instance-group="instanceGroup"
|
||||
:selection-anchor-instance-id="
|
||||
anchorInstance?.groupId === instanceGroup.id ? anchorInstance.instanceId : null
|
||||
"
|
||||
@toggle-selection="
|
||||
(instanceId: string, shiftKey: boolean) =>
|
||||
handleToggleInstance(instanceGroup.id, instanceId, shiftKey)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Transition>
|
||||
</section>
|
||||
</InstanceGroupDnd>
|
||||
<LibrarySelectionActionBar />
|
||||
<GroupInstancesModal />
|
||||
<ConfirmDeleteInstanceModal
|
||||
:ref="setConfirmDeleteModal"
|
||||
:instances="currentDeleteInstances"
|
||||
@delete="deleteInstance"
|
||||
/>
|
||||
<IconEditorModal
|
||||
:ref="setIconEditorModal"
|
||||
:instance-id="currentIconEditorInstance?.id"
|
||||
:config="currentIconEditorInstance?.icon_config"
|
||||
@saved="handleInstanceIconSaved"
|
||||
/>
|
||||
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
|
||||
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
|
||||
<template #add_to_favorites>
|
||||
<StarIcon /> {{ formatMessage(messages.addToFavorites) }}
|
||||
</template>
|
||||
<template #remove_from_favorites>
|
||||
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" />
|
||||
{{ formatMessage(messages.removeFromFavorites) }}
|
||||
</template>
|
||||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||||
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
|
||||
<template #edit_icon> <EditIcon /> {{ formatMessage(messages.editIcon) }} </template>
|
||||
<template #select_icon> <UploadIcon /> {{ formatMessage(messages.selectIcon) }} </template>
|
||||
<template #replace_icon> <UploadIcon /> {{ formatMessage(messages.replaceIcon) }} </template>
|
||||
<template #create_icon> <PaletteIcon /> {{ formatMessage(messages.createIcon) }} </template>
|
||||
<template #edit_created_icon>
|
||||
<PaletteIcon /> {{ formatMessage(messages.editCreatedIcon) }}
|
||||
</template>
|
||||
<template #remove_icon> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
|
||||
<template #duplicate>
|
||||
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
|
||||
</template>
|
||||
<template #delete> <TrashIcon /> {{ formatMessage(messages.delete) }} </template>
|
||||
<template #open> <FolderOpenIcon /> {{ formatMessage(messages.openFolder) }} </template>
|
||||
<template #copy> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
|
||||
<template #remove_from_group>
|
||||
<MinusIcon /> {{ formatMessage(messages.removeFromGroup) }}
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.instance-group-reorder-ghost) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
:global(html.instance-group-reordering),
|
||||
:global(html.instance-group-reordering *) {
|
||||
-webkit-user-select: none !important;
|
||||
cursor: grabbing !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
:global(.instance-group-reorder-fallback) {
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import DragPreview from '@/components/ui/library/instance-group/drag-preview.vue'
|
||||
import {
|
||||
gatherDuration,
|
||||
type InstanceDragGatherItem,
|
||||
} from '@/components/ui/library/instance-group/use-instance-drag-gather'
|
||||
|
||||
const props = defineProps<{
|
||||
items: InstanceDragGatherItem[]
|
||||
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.instance.id"
|
||||
class="fixed"
|
||||
:style="itemContainerStyles[index]"
|
||||
>
|
||||
<div
|
||||
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
|
||||
:style="itemStyles[index]"
|
||||
>
|
||||
<DragPreview :instance="item.instance" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { TagItem } from '@modrinth/ui'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
|
||||
import { gatherDuration } from '@/components/ui/library/instance-group/use-instance-drag-gather'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
count?: number
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
},
|
||||
)
|
||||
|
||||
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-[20px] 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-[20px] 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'"
|
||||
/>
|
||||
<InstanceCardView :instance="instance" class="opacity-90 shadow-lg">
|
||||
<template #overlay>
|
||||
<TagItem
|
||||
v-if="count > 1"
|
||||
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-2 font-semibold tabular-nums text-contrast motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
|
||||
>
|
||||
{{ count }}
|
||||
</TagItem>
|
||||
</template>
|
||||
</InstanceCardView>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownIcon, ArrowUpIcon, EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { defineMessages, IconButton, useVIntl } from '@modrinth/ui'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
deleting?: boolean
|
||||
canMoveDown: boolean
|
||||
canMoveUp: boolean
|
||||
onAddToGroup: () => void
|
||||
onDeleteGroup: () => void
|
||||
onEditGroupName: () => void
|
||||
onMoveDown: () => void
|
||||
onMoveUp: () => void
|
||||
showDelete?: boolean
|
||||
showEdit?: boolean
|
||||
}>(),
|
||||
{
|
||||
deleting: false,
|
||||
showDelete: true,
|
||||
showEdit: true,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
addToGroup: {
|
||||
id: 'app.library.group.context-menu.add-to-group',
|
||||
defaultMessage: 'Add to group',
|
||||
},
|
||||
editGroupName: {
|
||||
id: 'app.library.group.edit-name',
|
||||
defaultMessage: 'Edit group name',
|
||||
},
|
||||
deleteGroup: {
|
||||
id: 'app.library.group.delete',
|
||||
defaultMessage: 'Delete group',
|
||||
},
|
||||
moveGroupUp: {
|
||||
id: 'app.library.group.move-up',
|
||||
defaultMessage: 'Move group up',
|
||||
},
|
||||
moveGroupDown: {
|
||||
id: 'app.library.group.move-down',
|
||||
defaultMessage: 'Move group down',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="instance-group-reorder-ignore 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="onMoveUp"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.moveGroupDown)"
|
||||
:label="formatMessage(messages.moveGroupDown)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:disabled="!canMoveDown"
|
||||
@click.stop="onMoveDown"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="showEdit"
|
||||
v-tooltip="formatMessage(messages.editGroupName)"
|
||||
:label="formatMessage(messages.editGroupName)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="onEditGroupName"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.addToGroup)"
|
||||
:label="formatMessage(messages.addToGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
@click.stop="onAddToGroup"
|
||||
>
|
||||
<SquarePlusIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="showDelete"
|
||||
v-tooltip="formatMessage(messages.deleteGroup)"
|
||||
:label="formatMessage(messages.deleteGroup)"
|
||||
type="quiet"
|
||||
size="sm"
|
||||
:disabled="deleting"
|
||||
@click.stop="onDeleteGroup"
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,560 @@
|
||||
<script setup lang="ts">
|
||||
import { useDroppable } from '@dnd-kit/vue'
|
||||
import {
|
||||
DropdownIcon,
|
||||
EditIcon,
|
||||
PlusIcon,
|
||||
SquarePlusIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
InlineEditableText,
|
||||
NewModal,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
|
||||
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
|
||||
import type {
|
||||
InstanceCard as InstanceCardExposed,
|
||||
InstanceGroup as InstanceGroupType,
|
||||
} from '@/components/ui/library/use-library'
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { FAVORITES_GROUP_ID, MAX_INSTANCE_GROUP_NAME_LENGTH } from '@/helpers/instance-groups'
|
||||
|
||||
const INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY = 500
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
canDragReorder?: boolean
|
||||
hideHeader?: boolean
|
||||
instanceGroup: InstanceGroupType
|
||||
selectionAnchorInstanceId?: string | null
|
||||
}>(),
|
||||
{
|
||||
canDragReorder: false,
|
||||
hideHeader: false,
|
||||
selectionAnchorInstanceId: null,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const appSettings = useAppSettings()
|
||||
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
isSectionCollapsed,
|
||||
setSectionCollapsed,
|
||||
isSearching,
|
||||
deleteGroup,
|
||||
renameGroup,
|
||||
canMoveGroupUp,
|
||||
canMoveGroupDown,
|
||||
moveGroup,
|
||||
groupIdPendingNameEdit,
|
||||
completePendingGroupNameEdit,
|
||||
handleInstanceContextMenu,
|
||||
openGroupInstancesModal,
|
||||
displayState,
|
||||
activeInstanceGroupDrag,
|
||||
instanceGroupDragTarget,
|
||||
getInstanceGroupDropState,
|
||||
} = useLibrary()
|
||||
const showCreationModal = inject<() => void>('showCreationModal')
|
||||
|
||||
const instanceComponents = new Map<string, InstanceCardExposed>()
|
||||
const groupDropTarget = ref<HTMLElement>()
|
||||
const groupAccordion = ref<InstanceType<typeof Accordion>>()
|
||||
const groupOptions = ref<InstanceType<typeof ContextMenu>>()
|
||||
const groupNameInput = ref<InstanceType<typeof InlineEditableText>>()
|
||||
const confirmDeleteGroupModal = ref<InstanceType<typeof NewModal>>()
|
||||
const instanceGridContent = ref<HTMLElement>()
|
||||
const instanceGridHeight = ref<number>()
|
||||
const deletingGroup = ref(false)
|
||||
const groupName = ref(props.instanceGroup.key)
|
||||
const isUngrouped = computed(() => props.instanceGroup.id === 'group:none')
|
||||
const isFavorites = computed(() => props.instanceGroup.id === FAVORITES_GROUP_ID)
|
||||
const isCustomGroup = computed(
|
||||
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
|
||||
)
|
||||
const isReorderableGroup = computed(
|
||||
() => displayState.value.group === 'Group' && !isFavorites.value,
|
||||
)
|
||||
const groupContextMenuOpen = ref(false)
|
||||
const isGroupToggleBlocked = computed(
|
||||
() => isSearching.value || groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
|
||||
)
|
||||
let shouldSkipGroupToggle = false
|
||||
let groupToggleEventToSkip: MouseEvent | undefined
|
||||
let instanceGridResizeObserver: ResizeObserver | undefined
|
||||
let instanceGridObserverActivationTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-selection', instanceId: string, shiftKey: boolean): void
|
||||
}>()
|
||||
|
||||
useDroppable({
|
||||
id: computed(() => `instance-group:${props.instanceGroup.id}`),
|
||||
element: groupDropTarget,
|
||||
disabled: computed(() => displayState.value.group !== 'Group'),
|
||||
data: computed(() => ({
|
||||
groupId: props.instanceGroup.id,
|
||||
})),
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
ungrouped: {
|
||||
id: 'app.library.group.ungrouped',
|
||||
defaultMessage: 'Ungrouped',
|
||||
},
|
||||
favorites: {
|
||||
id: 'app.library.group.favorites',
|
||||
defaultMessage: 'Favorites',
|
||||
},
|
||||
collapseGroup: { id: 'app.library.group.collapse', defaultMessage: 'Collapse group' },
|
||||
expandGroup: { id: 'app.library.group.expand', defaultMessage: 'Expand group' },
|
||||
deleteGroup: {
|
||||
id: 'app.library.group.delete',
|
||||
defaultMessage: 'Delete group',
|
||||
},
|
||||
editGroupName: {
|
||||
id: 'app.library.group.edit-name',
|
||||
defaultMessage: 'Edit group name',
|
||||
},
|
||||
renameGroupFailed: {
|
||||
id: 'app.library.group.rename-failed',
|
||||
defaultMessage: 'Unable to rename group',
|
||||
},
|
||||
groupNameEmpty: {
|
||||
id: 'app.library.group.name-empty',
|
||||
defaultMessage: 'Group names cannot be empty.',
|
||||
},
|
||||
groupNameTooLong: {
|
||||
id: 'app.library.group.name-too-long',
|
||||
defaultMessage: 'Group names cannot be longer than {maxLength} characters.',
|
||||
},
|
||||
groupNameReserved: {
|
||||
id: 'app.library.group.name-reserved',
|
||||
defaultMessage: '"None" is reserved and cannot be used as a group name.',
|
||||
},
|
||||
deleteGroupDescription: {
|
||||
id: 'app.library.group.delete-description',
|
||||
defaultMessage: 'Instances in this group will be ungrouped.',
|
||||
},
|
||||
newInstance: {
|
||||
id: 'app.library.context-menu.create-instance',
|
||||
defaultMessage: 'New instance',
|
||||
},
|
||||
addToGroup: {
|
||||
id: 'app.library.group.context-menu.add-to-group',
|
||||
defaultMessage: 'Add to group',
|
||||
},
|
||||
emptyGroup: {
|
||||
id: 'app.library.group.empty',
|
||||
defaultMessage: 'Drag and drop to add instances.',
|
||||
},
|
||||
})
|
||||
|
||||
function openInstanceContextMenu(event: MouseEvent, instanceId: string, instanceGroupId: string) {
|
||||
const instanceComponent = instanceComponents.get(instanceId)
|
||||
if (!instanceComponent) return
|
||||
|
||||
handleInstanceContextMenu(event, instanceComponent, instanceGroupId)
|
||||
}
|
||||
|
||||
function setInstanceComponent(instanceId: string, component: unknown) {
|
||||
if (component) {
|
||||
instanceComponents.set(instanceId, component as InstanceCardExposed)
|
||||
} else {
|
||||
instanceComponents.delete(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGroup() {
|
||||
if (deletingGroup.value) return
|
||||
|
||||
deletingGroup.value = true
|
||||
const deleted = await deleteGroup(props.instanceGroup.id)
|
||||
deletingGroup.value = false
|
||||
|
||||
if (deleted) {
|
||||
confirmDeleteGroupModal.value?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
function requestGroupDeletion() {
|
||||
if (isUngrouped.value || isFavorites.value) return
|
||||
|
||||
if (props.instanceGroup.instances.length > 0) {
|
||||
confirmDeleteGroupModal.value?.show()
|
||||
} else {
|
||||
void removeGroup()
|
||||
}
|
||||
}
|
||||
|
||||
function openGroupContextMenu(event: MouseEvent) {
|
||||
groupContextMenuOpen.value = true
|
||||
groupOptions.value?.showMenu(
|
||||
event,
|
||||
props.instanceGroup,
|
||||
isFavorites.value
|
||||
? [{ name: 'new_instance' }]
|
||||
: isCustomGroup.value
|
||||
? [
|
||||
{ name: 'new_instance' },
|
||||
{ name: 'add_instances' },
|
||||
{ name: 'edit_name' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'delete_group', color: 'danger' },
|
||||
]
|
||||
: [{ name: 'new_instance' }],
|
||||
)
|
||||
}
|
||||
|
||||
function handleGroupOption({ option }: { option: string }) {
|
||||
if (option === 'new_instance') {
|
||||
showCreationModal?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (option === 'add_instances') {
|
||||
openGroupInstancesModal(props.instanceGroup.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (option === 'edit_name') {
|
||||
void groupNameInput.value?.startEditing()
|
||||
return
|
||||
}
|
||||
|
||||
if (option === 'delete_group') {
|
||||
requestGroupDeletion()
|
||||
}
|
||||
}
|
||||
|
||||
function prepareGroupToggle(event: PointerEvent) {
|
||||
const editor = groupNameInput.value
|
||||
const contextMenuWasOpen = groupContextMenuOpen.value
|
||||
if (contextMenuWasOpen) {
|
||||
groupOptions.value?.hideMenu()
|
||||
}
|
||||
|
||||
shouldSkipGroupToggle = Boolean(
|
||||
contextMenuWasOpen ||
|
||||
(editor?.isEditing && event.target instanceof Node && !editor.$el.contains(event.target)),
|
||||
)
|
||||
}
|
||||
|
||||
function captureGroupClick(event: MouseEvent) {
|
||||
groupToggleEventToSkip = shouldSkipGroupToggle ? event : undefined
|
||||
shouldSkipGroupToggle = false
|
||||
}
|
||||
|
||||
function toggleGroup(event: MouseEvent) {
|
||||
if (event === groupToggleEventToSkip) {
|
||||
groupToggleEventToSkip = undefined
|
||||
return
|
||||
}
|
||||
if (isSearching.value) return
|
||||
|
||||
if (groupAccordion.value?.isOpen) {
|
||||
groupAccordion.value.close()
|
||||
} else {
|
||||
groupAccordion.value?.open()
|
||||
}
|
||||
}
|
||||
|
||||
function validateGroupName(value: string) {
|
||||
const normalizedGroupName = value.trim()
|
||||
let reason: string | undefined
|
||||
|
||||
if (normalizedGroupName.length === 0) {
|
||||
reason = formatMessage(messages.groupNameEmpty)
|
||||
} else if (normalizedGroupName.length > MAX_INSTANCE_GROUP_NAME_LENGTH) {
|
||||
reason = formatMessage(messages.groupNameTooLong, {
|
||||
maxLength: MAX_INSTANCE_GROUP_NAME_LENGTH,
|
||||
})
|
||||
} else if (normalizedGroupName.toLowerCase() === 'none') {
|
||||
reason = formatMessage(messages.groupNameReserved)
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.renameGroupFailed),
|
||||
text: reason,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function updateGroupName(value: string) {
|
||||
return await renameGroup(props.instanceGroup.id, value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.instanceGroup.key,
|
||||
(value) => {
|
||||
groupName.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.instanceGroup.id, groupIdPendingNameEdit],
|
||||
async ([groupId, pendingGroupId]) => {
|
||||
if (groupId !== pendingGroupId) return
|
||||
|
||||
await nextTick()
|
||||
groupDropTarget.value?.scrollIntoView({ block: 'nearest' })
|
||||
await groupNameInput.value?.startEditing()
|
||||
completePendingGroupNameEdit(groupId)
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.hideHeader,
|
||||
(hideHeader) => {
|
||||
if (hideHeader) {
|
||||
groupAccordion.value?.open()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
function stopInstanceGridResizeObserver() {
|
||||
clearTimeout(instanceGridObserverActivationTimeout)
|
||||
instanceGridResizeObserver?.disconnect()
|
||||
}
|
||||
|
||||
function startInstanceGridResizeObserver() {
|
||||
stopInstanceGridResizeObserver()
|
||||
instanceGridHeight.value = undefined
|
||||
instanceGridObserverActivationTimeout = setTimeout(() => {
|
||||
instanceGridObserverActivationTimeout = undefined
|
||||
|
||||
const gridContent = instanceGridContent.value
|
||||
if (!gridContent?.isConnected) return
|
||||
|
||||
instanceGridHeight.value = gridContent.getBoundingClientRect().height
|
||||
instanceGridResizeObserver = new ResizeObserver(() => {
|
||||
if (!gridContent.isConnected) return
|
||||
instanceGridHeight.value = gridContent.getBoundingClientRect().height
|
||||
})
|
||||
instanceGridResizeObserver.observe(gridContent)
|
||||
}, INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY)
|
||||
}
|
||||
|
||||
onActivated(startInstanceGridResizeObserver)
|
||||
onDeactivated(stopInstanceGridResizeObserver)
|
||||
onMounted(startInstanceGridResizeObserver)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="groupDropTarget"
|
||||
class="instance-group group/instance-container relative select-none pb-3"
|
||||
@contextmenu.prevent.stop="openGroupContextMenu"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="!opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="!opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
activeInstanceGroupDrag &&
|
||||
instanceGroupDragTarget === instanceGroup.id &&
|
||||
getInstanceGroupDropState(instanceGroup.id).canDrop
|
||||
"
|
||||
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 opacity-40 border-dashed border-contrast bg-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
v-if="!hideHeader"
|
||||
class="group/header h-10 flex w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||
:class="{
|
||||
'instance-group-reorder-handle': isReorderableGroup && canDragReorder,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="group/open-target flex min-w-0 items-center gap-2"
|
||||
:class="isGroupToggleBlocked ? 'cursor-default' : 'cursor-pointer'"
|
||||
@click="toggleGroup"
|
||||
@click.capture="captureGroupClick"
|
||||
@pointerdown.capture="prepareGroupToggle"
|
||||
>
|
||||
<button
|
||||
class="flex shrink-0 items-center border-0 bg-transparent p-0"
|
||||
:class="isGroupToggleBlocked ? 'cursor-default' : 'cursor-pointer'"
|
||||
type="button"
|
||||
:aria-expanded="groupAccordion?.isOpen"
|
||||
:aria-label="
|
||||
formatMessage(groupAccordion?.isOpen ? messages.collapseGroup : messages.expandGroup)
|
||||
"
|
||||
@click.stop="toggleGroup"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="size-5 shrink-0 text-secondary transition-all duration-300 group-hover/open-target:text-primary"
|
||||
:class="{ 'rotate-180': groupAccordion?.isOpen }"
|
||||
/>
|
||||
</button>
|
||||
<InlineEditableText
|
||||
v-if="!isUngrouped && !isFavorites"
|
||||
ref="groupNameInput"
|
||||
v-model="groupName"
|
||||
activation-mode="manual"
|
||||
class="text-base font-semibold !h-10 text-primary select-none group-hover/open-target:text-contrast"
|
||||
:edit-label="formatMessage(commonMessages.renameButton)"
|
||||
max-width="24rem"
|
||||
icon-text-class="select-none"
|
||||
:max-length="MAX_INSTANCE_GROUP_NAME_LENGTH"
|
||||
:on-change="updateGroupName"
|
||||
:validate="validateGroupName"
|
||||
/>
|
||||
<span
|
||||
v-else-if="isUngrouped"
|
||||
class="text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.ungrouped) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1.5 text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.favorites) }}
|
||||
</span>
|
||||
<TagItem
|
||||
v-if="instanceGroup.instances.length"
|
||||
class="shrink-0 border-surface-3 bg-surface-2"
|
||||
>
|
||||
{{ instanceGroup.instances.length }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<GroupActionButtons
|
||||
v-if="isCustomGroup || isUngrouped"
|
||||
:can-move-down="canMoveGroupDown(instanceGroup.id)"
|
||||
:can-move-up="canMoveGroupUp(instanceGroup.id)"
|
||||
:deleting="deletingGroup"
|
||||
:on-add-to-group="() => openGroupInstancesModal(instanceGroup.id)"
|
||||
:on-delete-group="requestGroupDeletion"
|
||||
:on-edit-group-name="() => groupNameInput?.startEditing()"
|
||||
:on-move-down="() => moveGroup(instanceGroup.id, 1)"
|
||||
:on-move-up="() => moveGroup(instanceGroup.id, -1)"
|
||||
:show-delete="!isUngrouped"
|
||||
:show-edit="!isUngrouped"
|
||||
/>
|
||||
</div>
|
||||
<Accordion
|
||||
ref="groupAccordion"
|
||||
:open-by-default="hideHeader || !isSectionCollapsed(instanceGroup.id)"
|
||||
:force-open="isSearching"
|
||||
class="w-full"
|
||||
@on-open="setSectionCollapsed(instanceGroup.id, false)"
|
||||
@on-close="setSectionCollapsed(instanceGroup.id, true)"
|
||||
>
|
||||
<div
|
||||
class="mt-2.5 overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none"
|
||||
:style="{
|
||||
height: instanceGridHeight === undefined ? undefined : `${instanceGridHeight}px`,
|
||||
}"
|
||||
>
|
||||
<div ref="instanceGridContent">
|
||||
<TransitionGroup
|
||||
tag="section"
|
||||
class="grid min-h-[45px] w-full gap-3 overflow-y-auto scroll-smooth"
|
||||
:class="
|
||||
compactMode
|
||||
? 'grid-cols-[repeat(auto-fill,minmax(min(15rem,100%),1fr))]'
|
||||
: 'grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))] max-xl:grid-cols-[repeat(auto-fill,minmax(min(8rem,100%),1fr))]'
|
||||
"
|
||||
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
enter-active-class="transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
>
|
||||
<div
|
||||
v-for="instance in instanceGroup.instances"
|
||||
:key="instance.id"
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
<InstanceCard
|
||||
:ref="(component: unknown) => setInstanceComponent(instance.id, component)"
|
||||
:instance="instance"
|
||||
:instance-group-id="instanceGroup.id"
|
||||
:is-selection-anchor="selectionAnchorInstanceId === instance.id"
|
||||
@toggle-selection="
|
||||
(shiftKey: boolean) => emit('toggle-selection', instance.id, shiftKey)
|
||||
"
|
||||
@contextmenu.prevent.stop="
|
||||
(event: MouseEvent) =>
|
||||
openInstanceContextMenu(event, instance.id, instanceGroup.id)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="instanceGroup.instances.length === 0"
|
||||
key="empty-group"
|
||||
class="col-span-full m-0 pt-1 pl-0.5 text-base font-base text-secondary opacity-80"
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
ref="groupOptions"
|
||||
@menu-closed="groupContextMenuOpen = false"
|
||||
@option-clicked="handleGroupOption"
|
||||
>
|
||||
<template #new_instance> <PlusIcon /> {{ formatMessage(messages.newInstance) }} </template>
|
||||
<template #add_instances>
|
||||
<SquarePlusIcon /> {{ 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-primary">
|
||||
{{ 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,80 @@
|
||||
<script setup lang="ts">
|
||||
import { Avatar, truncatedTooltip } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { getInstanceIconUrl } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
selected?: boolean
|
||||
}>(),
|
||||
{
|
||||
selected: false,
|
||||
},
|
||||
)
|
||||
|
||||
const iconSrc = computed(() => getInstanceIconUrl(props.instance.icon_path))
|
||||
const appSettings = useAppSettings()
|
||||
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
|
||||
|
||||
const nameRef = ref<HTMLElement | null>(null)
|
||||
const versionRef = ref<HTMLElement | null>(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex w-full min-w-0 select-none overflow-clip border border-solid bg-surface-3 text-left transition-all"
|
||||
:class="{
|
||||
'flex-row items-center justify-start gap-2.5 rounded-xl p-2.5': compactMode,
|
||||
'flex-col items-start justify-end gap-3 rounded-[20px] p-3': !compactMode,
|
||||
'[border-color:color-mix(in_srgb,var(--color-text-primary)_40%,transparent)] brightness-110':
|
||||
selected,
|
||||
'border-surface-4': !selected,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="relative flex shrink-0 items-center overflow-clip"
|
||||
:class="compactMode ? 'size-10 rounded-lg' : 'aspect-square min-w-full rounded-2xl'"
|
||||
>
|
||||
<Avatar
|
||||
class="pointer-events-none outline-none"
|
||||
:class="compactMode ? '!rounded-lg' : '!rounded-2xl'"
|
||||
size="100%"
|
||||
:src="iconSrc"
|
||||
:tint-by="instance.id"
|
||||
alt=""
|
||||
no-shadow
|
||||
/>
|
||||
<slot name="loading" :compact="compactMode" />
|
||||
<div
|
||||
class="absolute z-[1] flex items-center justify-center"
|
||||
:class="compactMode ? 'inset-0' : 'bottom-1.5 right-1.5 size-12'"
|
||||
>
|
||||
<slot name="leading" :compact="compactMode" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 w-full flex-col items-start justify-center gap-1 px-0.5"
|
||||
:class="{ 'pr-10': compactMode }"
|
||||
>
|
||||
<p
|
||||
ref="nameRef"
|
||||
v-tooltip="truncatedTooltip(nameRef, instance.name)"
|
||||
class="m-0 w-full truncate text-base font-semibold leading-5 text-contrast"
|
||||
>
|
||||
{{ instance.name }}
|
||||
</p>
|
||||
<p
|
||||
ref="versionRef"
|
||||
v-tooltip="truncatedTooltip(versionRef, `${instance.loader} ${instance.game_version}`)"
|
||||
class="m-0 w-full truncate text-sm font-medium capitalize leading-[18px] text-primary"
|
||||
>
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="overlay" :compact="compactMode" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
|
||||
import { CheckIcon, DownloadIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
|
||||
import { defineMessages, IconButton, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { useEventListener, useMagicKeys } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
|
||||
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { handleSevereError } from '@/composables/use-error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
|
||||
type ProcessEvent = 'installing' | 'launched' | 'finished'
|
||||
|
||||
const LOADING_INDICATOR_DELAY_MS = 100
|
||||
|
||||
const instanceCardSensors = [
|
||||
PointerSensor.configure({
|
||||
preventActivation: () => false,
|
||||
}),
|
||||
KeyboardSensor,
|
||||
]
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
selectInstance: {
|
||||
id: 'app.library.instance.select-with-name',
|
||||
defaultMessage: 'Select {name}',
|
||||
},
|
||||
deselectInstance: {
|
||||
id: 'app.library.instance.deselect-with-name',
|
||||
defaultMessage: 'Deselect {name}',
|
||||
},
|
||||
openInstance: {
|
||||
id: 'app.library.instance.open-with-name',
|
||||
defaultMessage: 'Open {name}',
|
||||
},
|
||||
loading: { id: 'app.library.instance.loading', defaultMessage: 'Instance is loading...' },
|
||||
installing: { id: 'app.library.instance.installing', defaultMessage: 'Installing...' },
|
||||
stop: { id: 'app.library.instance.stop', defaultMessage: 'Stop' },
|
||||
repair: { id: 'app.library.instance.repair', defaultMessage: 'Repair' },
|
||||
play: { id: 'app.library.instance.play', defaultMessage: 'Play' },
|
||||
select: { id: 'app.library.instance.select', defaultMessage: 'Select instance' },
|
||||
deselect: { id: 'app.library.instance.deselect', defaultMessage: 'Deselect instance' },
|
||||
})
|
||||
const {
|
||||
displayState,
|
||||
selectedLibraryInstances,
|
||||
isLibraryInstanceSelectionActive,
|
||||
activeDraggedInstanceKeys,
|
||||
} = useLibrary()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
instanceGroupId: string
|
||||
isSelectionAnchor?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-selection', shiftKey: boolean): void
|
||||
}>()
|
||||
|
||||
const instanceCardElement = ref<InstanceType<typeof InstanceCardView> | null>(null)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentEvent = ref<ProcessEvent | null>(null)
|
||||
const isPrimaryPointerDown = ref(false)
|
||||
const modLoading = computed(
|
||||
() =>
|
||||
loading.value ||
|
||||
currentEvent.value === 'installing' ||
|
||||
(currentEvent.value === 'launched' && !playing.value),
|
||||
)
|
||||
const installing = computed(() => props.instance.install_stage.includes('installing'))
|
||||
const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
const loadingIndicatorRequested = computed(
|
||||
() => !playing.value && (modLoading.value || installing.value),
|
||||
)
|
||||
const loadingIndicatorVisible = ref(false)
|
||||
|
||||
watch(
|
||||
loadingIndicatorRequested,
|
||||
(requested, _, onCleanup) => {
|
||||
loadingIndicatorVisible.value = false
|
||||
if (!requested) return
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
loadingIndicatorVisible.value = true
|
||||
}, LOADING_INDICATOR_DELAY_MS)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const selectionKey = computed(() =>
|
||||
getLibraryInstanceSelectionKey({
|
||||
instanceId: props.instance.id,
|
||||
groupId: props.instanceGroupId,
|
||||
}),
|
||||
)
|
||||
const selected = computed(() => selectedLibraryInstances.value.has(selectionKey.value))
|
||||
const keys = useMagicKeys()
|
||||
const holdingShift = computed(() => keys.shift.value)
|
||||
const isPartOfActiveDrag = computed(() => activeDraggedInstanceKeys.value.has(selectionKey.value))
|
||||
const { isDragging } = useDraggable({
|
||||
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
|
||||
element: instanceCardElement,
|
||||
disabled: computed(() => displayState.value.group !== 'Group'),
|
||||
sensors: instanceCardSensors,
|
||||
data: computed(() => ({
|
||||
instanceId: props.instance.id,
|
||||
fromGroup: props.instanceGroupId,
|
||||
})),
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const seeInstance = async () => {
|
||||
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
|
||||
}
|
||||
|
||||
const toggleSelection = (event?: MouseEvent) => {
|
||||
emit('toggle-selection', event?.shiftKey ?? false)
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
isPrimaryPointerDown.value =
|
||||
event.button === 0 &&
|
||||
!(event.target instanceof Element && event.target.closest('.selection-button'))
|
||||
}
|
||||
|
||||
const resetPrimaryPointer = () => {
|
||||
isPrimaryPointerDown.value = false
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointerup', resetPrimaryPointer)
|
||||
useEventListener(window, 'pointercancel', resetPrimaryPointer)
|
||||
useEventListener(window, 'blur', resetPrimaryPointer)
|
||||
|
||||
const activateCard = (event: MouseEvent) => {
|
||||
if (isLibraryInstanceSelectionActive.value || event.shiftKey) {
|
||||
toggleSelection(event)
|
||||
} else {
|
||||
void seeInstance()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardKeydown = (event: KeyboardEvent) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (isLibraryInstanceSelectionActive.value) {
|
||||
toggleSelection()
|
||||
} else {
|
||||
void seeInstance()
|
||||
}
|
||||
} else if (event.key === ' ' && isLibraryInstanceSelectionActive.value) {
|
||||
event.preventDefault()
|
||||
toggleSelection()
|
||||
}
|
||||
}
|
||||
|
||||
const checkProcess = async () => {
|
||||
const runningProcesses = (await get_by_instance_id(props.instance.id).catch(handleError)) ?? []
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
const play = async (event: MouseEvent | null, context: string) => {
|
||||
event?.stopPropagation()
|
||||
if (props.instance.quarantined) return
|
||||
loading.value = true
|
||||
await run(props.instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
})
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const stop = async (event: MouseEvent | null, context: string) => {
|
||||
event?.stopPropagation()
|
||||
playing.value = false
|
||||
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
|
||||
const repair = async (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (props.instance.quarantined) return
|
||||
|
||||
if (
|
||||
props.instance.install_stage !== 'pack_installed' &&
|
||||
(props.instance.link?.type === 'modrinth_modpack' ||
|
||||
props.instance.link?.type === 'server_project_modpack')
|
||||
) {
|
||||
await install_pack_to_existing_instance(props.instance.id, {
|
||||
type: 'fromVersionId',
|
||||
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
|
||||
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
|
||||
title: props.instance.name,
|
||||
}).catch(handleError)
|
||||
} else {
|
||||
await install_existing_instance(props.instance.id, false).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
const openFolder = async () => {
|
||||
await showInstanceInFolder(props.instance.id)
|
||||
}
|
||||
|
||||
const addContent = async () => {
|
||||
if (props.instance.quarantined) return
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: props.instance.id },
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
play,
|
||||
stop,
|
||||
seeInstance,
|
||||
openFolder,
|
||||
addContent,
|
||||
instance: props.instance,
|
||||
})
|
||||
|
||||
useAppEvent('process', (event) => {
|
||||
if (event.instance_id === props.instance.id) {
|
||||
currentEvent.value = event.event
|
||||
playing.value = event.event === 'launched'
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void checkProcess()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InstanceCardView
|
||||
ref="instanceCardElement"
|
||||
class="group/card cursor-pointer -outline-offset-2 focus-visible:!outline-2 hover:brightness-110"
|
||||
:class="{
|
||||
'!scale-100': isDragging,
|
||||
'opacity-50': isPartOfActiveDrag,
|
||||
'scale-[0.95]': isPrimaryPointerDown && !isLibraryInstanceSelectionActive,
|
||||
}"
|
||||
:instance="instance"
|
||||
:selected="selected"
|
||||
data-library-instance-card
|
||||
:data-instance-id="instance.id"
|
||||
:data-instance-group="instanceGroupId"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
isLibraryInstanceSelectionActive
|
||||
? formatMessage(selected ? messages.deselectInstance : messages.selectInstance, {
|
||||
name: instance.name,
|
||||
})
|
||||
: formatMessage(messages.openInstance, { name: instance.name })
|
||||
"
|
||||
:aria-pressed="isLibraryInstanceSelectionActive ? selected : undefined"
|
||||
@click="activateCard"
|
||||
@keydown="handleCardKeydown"
|
||||
@mouseenter="checkProcess"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<template #loading="{ compact }">
|
||||
<div
|
||||
v-if="loadingIndicatorVisible"
|
||||
class="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center"
|
||||
>
|
||||
<div class="absolute inset-0 bg-surface-1 opacity-30" />
|
||||
<SpinnerIcon
|
||||
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
|
||||
class="relative animate-spin text-contrast"
|
||||
:class="compact ? 'size-5' : 'size-[30%]'"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #leading="{ compact }">
|
||||
<div
|
||||
class="relative flex shrink-0 items-center justify-center"
|
||||
:class="compact ? 'size-10' : 'size-12'"
|
||||
>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
v-if="playing"
|
||||
v-tooltip="formatMessage(messages.stop)"
|
||||
:label="formatMessage(messages.stop)"
|
||||
type="colored"
|
||||
color="red"
|
||||
:size="compact ? 'md' : 'lg'"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="
|
||||
!modLoading &&
|
||||
!installing &&
|
||||
!isLibraryInstanceSelectionActive &&
|
||||
!installed &&
|
||||
!instance.quarantined
|
||||
"
|
||||
v-tooltip="formatMessage(messages.repair)"
|
||||
:label="formatMessage(messages.repair)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
:size="compact ? 'md' : 'lg'"
|
||||
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="
|
||||
!modLoading &&
|
||||
!installing &&
|
||||
!isLibraryInstanceSelectionActive &&
|
||||
!instance.quarantined
|
||||
"
|
||||
v-tooltip="formatMessage(messages.play)"
|
||||
:label="formatMessage(messages.play)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
:size="compact ? 'md' : 'lg'"
|
||||
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<PlayIcon class="translate-x-px" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #overlay="{ compact }">
|
||||
<button
|
||||
type="button"
|
||||
class="selection-button group/selection absolute z-[2] flex size-[50px] cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
|
||||
:class="compact ? '-right-1 -top-1' : 'right-2 top-1.5'"
|
||||
:aria-label="formatMessage(selected ? messages.deselect : messages.select)"
|
||||
:aria-pressed="selected"
|
||||
@click.stop="toggleSelection"
|
||||
>
|
||||
<span
|
||||
v-tooltip="formatMessage(selected ? messages.deselect : messages.select)"
|
||||
class="relative flex items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover/card:opacity-100 group-hover/selection:brightness-125"
|
||||
:class="{
|
||||
'size-[20px]': compact,
|
||||
'size-[24px]': !compact,
|
||||
'border-0 !opacity-100': selected,
|
||||
'border-2 border-solid border-primary bg-transparent': !selected,
|
||||
'[outline:3px_solid_var(--color-purple)] outline-offset-1':
|
||||
holdingShift && isSelectionAnchor,
|
||||
}"
|
||||
>
|
||||
<span v-if="selected" class="absolute inset-0 rounded-full bg-contrast" />
|
||||
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</InstanceCardView>
|
||||
</template>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, toRef, watch } from 'vue'
|
||||
|
||||
import DragGather from '@/components/ui/library/instance-group/drag-gather.vue'
|
||||
import DragPreview from '@/components/ui/library/instance-group/drag-preview.vue'
|
||||
import { useInstanceDragGather } from '@/components/ui/library/instance-group/use-instance-drag-gather'
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
type InstanceDragData = {
|
||||
instanceId: string
|
||||
fromGroup: string
|
||||
}
|
||||
|
||||
type InstanceGroupDndDropData = {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
}>()
|
||||
|
||||
const GROUP_HOVER_OPEN_DELAY = 750
|
||||
|
||||
const {
|
||||
activeInstanceGroupDrag,
|
||||
instanceGroupDragPointer,
|
||||
instanceGroupDragStatus,
|
||||
instanceGroupDragTarget,
|
||||
isSectionCollapsed,
|
||||
setSectionCollapsed,
|
||||
startInstanceGroupDrag,
|
||||
updateInstanceGroupDrag,
|
||||
finishInstanceGroupDrag,
|
||||
setInstanceGroupDragTarget,
|
||||
getInstanceGroupDropState,
|
||||
moveDraggedInstancesToGroup,
|
||||
} = useLibrary()
|
||||
|
||||
const draggedInstances = computed(() => {
|
||||
const drag = activeInstanceGroupDrag.value
|
||||
if (!drag) return []
|
||||
|
||||
const draggedInstanceIds = new Set(drag.instances.map((selection) => selection.instanceId))
|
||||
return props.instances.filter((instance) => draggedInstanceIds.has(instance.id))
|
||||
})
|
||||
const draggedInstance = computed(() => {
|
||||
const drag = activeInstanceGroupDrag.value
|
||||
return drag
|
||||
? props.instances.find((instance) => instance.id === drag.primaryInstanceId)
|
||||
: undefined
|
||||
})
|
||||
const {
|
||||
items: gatherItems,
|
||||
target: gatherTarget,
|
||||
isGathering,
|
||||
start: startGather,
|
||||
updateTarget: updateGatherTarget,
|
||||
clear: clearGather,
|
||||
finish: finishGather,
|
||||
} = useInstanceDragGather(toRef(props, 'instances'))
|
||||
|
||||
const instanceGroupDragStatusPopover = ref<HTMLElement>()
|
||||
let statusPopoverFrame: number | undefined
|
||||
let groupHoverOpenTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const instanceGroupDragStatusStyle = computed(() => ({
|
||||
left: `${Math.min(instanceGroupDragPointer.value.x + 4, window.innerWidth - 220)}px`,
|
||||
top: `${Math.min(instanceGroupDragPointer.value.y + 4, window.innerHeight - 48)}px`,
|
||||
}))
|
||||
|
||||
const hoveredCollapsedGroupId = computed(() => {
|
||||
const target = instanceGroupDragTarget.value
|
||||
if (
|
||||
!activeInstanceGroupDrag.value ||
|
||||
!target ||
|
||||
!isSectionCollapsed(target) ||
|
||||
!getInstanceGroupDropState(target).canDrop
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return target
|
||||
})
|
||||
|
||||
watch(
|
||||
activeInstanceGroupDrag,
|
||||
(drag) => {
|
||||
if (statusPopoverFrame !== undefined) {
|
||||
cancelAnimationFrame(statusPopoverFrame)
|
||||
statusPopoverFrame = undefined
|
||||
}
|
||||
|
||||
const popover = instanceGroupDragStatusPopover.value
|
||||
if (!drag) {
|
||||
if (popover?.matches(':popover-open')) {
|
||||
popover.hidePopover()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
statusPopoverFrame = requestAnimationFrame(() => {
|
||||
statusPopoverFrame = undefined
|
||||
const currentPopover = instanceGroupDragStatusPopover.value
|
||||
if (activeInstanceGroupDrag.value && !currentPopover?.matches(':popover-open')) {
|
||||
currentPopover?.showPopover()
|
||||
}
|
||||
})
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function clearGroupHoverOpenTimeout() {
|
||||
if (groupHoverOpenTimeout !== undefined) {
|
||||
clearTimeout(groupHoverOpenTimeout)
|
||||
}
|
||||
groupHoverOpenTimeout = undefined
|
||||
}
|
||||
|
||||
watch(hoveredCollapsedGroupId, (target) => {
|
||||
clearGroupHoverOpenTimeout()
|
||||
if (!target) return
|
||||
|
||||
groupHoverOpenTimeout = setTimeout(() => {
|
||||
groupHoverOpenTimeout = undefined
|
||||
if (hoveredCollapsedGroupId.value === target) {
|
||||
setSectionCollapsed(target, false)
|
||||
}
|
||||
}, GROUP_HOVER_OPEN_DELAY)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (statusPopoverFrame !== undefined) {
|
||||
cancelAnimationFrame(statusPopoverFrame)
|
||||
}
|
||||
clearGroupHoverOpenTimeout()
|
||||
})
|
||||
|
||||
function isAltKeyPressed(event?: Event) {
|
||||
return event instanceof MouseEvent || event instanceof KeyboardEvent ? event.altKey : false
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
const sourceData = event.operation.source?.data as InstanceDragData | undefined
|
||||
if (!sourceData) return
|
||||
|
||||
const pointer = event.operation.position.current
|
||||
startInstanceGroupDrag(
|
||||
sourceData.instanceId,
|
||||
sourceData.fromGroup,
|
||||
pointer,
|
||||
isAltKeyPressed(event.nativeEvent),
|
||||
)
|
||||
startGather(activeInstanceGroupDrag.value, sourceData, pointer)
|
||||
}
|
||||
|
||||
function handleDragMove(event: DragMoveEvent) {
|
||||
const pointer = event.to ?? event.operation.position.current
|
||||
updateInstanceGroupDrag(pointer, isAltKeyPressed(event.nativeEvent))
|
||||
if (gatherItems.value.length > 0) {
|
||||
updateGatherTarget(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
const targetData = event.operation.target?.data as InstanceGroupDndDropData | undefined
|
||||
setInstanceGroupDragTarget(targetData?.groupId ?? null)
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
clearGroupHoverOpenTimeout()
|
||||
const targetData = event.operation.target?.data as InstanceGroupDndDropData | undefined
|
||||
let isMovingInstances = false
|
||||
if (!event.canceled && targetData) {
|
||||
const dropState = getInstanceGroupDropState(targetData.groupId)
|
||||
if (dropState.canDrop) {
|
||||
void moveDraggedInstancesToGroup(targetData.groupId, dropState.operation === 'add')
|
||||
isMovingInstances = true
|
||||
}
|
||||
}
|
||||
if (isMovingInstances) {
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
clearGather()
|
||||
finishInstanceGroupDrag()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DragDropProvider
|
||||
@drag-start="handleDragStart"
|
||||
@drag-move="handleDragMove"
|
||||
@drag-over="handleDragOver"
|
||||
@drag-end="handleDragEnd"
|
||||
>
|
||||
<slot />
|
||||
<Teleport to="body">
|
||||
<div class="pointer-events-none fixed inset-0 z-[9999]">
|
||||
<DragOverlay :drop-animation="null">
|
||||
<div
|
||||
v-if="draggedInstance"
|
||||
class="w-full transition-all duration-150 ease-out"
|
||||
:class="isGathering ? 'scale-[0.975]' : 'scale-100'"
|
||||
>
|
||||
<DragPreview :instance="draggedInstance" :count="draggedInstances.length" />
|
||||
</div>
|
||||
</DragOverlay>
|
||||
</div>
|
||||
</Teleport>
|
||||
</DragDropProvider>
|
||||
<DragGather
|
||||
v-if="gatherItems.length > 0"
|
||||
:items="gatherItems"
|
||||
:target="gatherTarget"
|
||||
@complete="finishGather"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="instanceGroupDragStatusPopover"
|
||||
popover="manual"
|
||||
class="pointer-events-none fixed inset-auto z-[9999] m-0 rounded-xl border border-solid border-surface-5 bg-surface-4 px-2 py-1.5 text-sm font-semibold text-contrast empty:hidden"
|
||||
:style="instanceGroupDragStatusStyle"
|
||||
>
|
||||
{{ instanceGroupDragStatus }}
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
import { onBeforeUnmount, type Ref, ref } from 'vue'
|
||||
|
||||
import type { ActiveInstanceGroupDrag } from '@/components/ui/library/use-library'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type InstanceDragData = {
|
||||
instanceId: string
|
||||
fromGroup: string
|
||||
}
|
||||
|
||||
export const gatherDuration = 500
|
||||
|
||||
export type InstanceDragGatherItem = {
|
||||
instance: GameInstance
|
||||
rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
export function useInstanceDragGather(instances: Ref<GameInstance[]>) {
|
||||
const items = ref<InstanceDragGatherItem[]>([])
|
||||
const target = ref<Point>({ x: 0, y: 0 })
|
||||
const targetOffset = ref<Point>({ x: 0, y: 0 })
|
||||
const isGathering = ref(false)
|
||||
let cleanupTimer: number | undefined
|
||||
|
||||
const clearCleanupTimer = () => {
|
||||
if (cleanupTimer !== undefined) {
|
||||
clearTimeout(cleanupTimer)
|
||||
cleanupTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
clearCleanupTimer()
|
||||
items.value = []
|
||||
targetOffset.value = { x: 0, y: 0 }
|
||||
isGathering.value = false
|
||||
}
|
||||
|
||||
const updateTarget = (pointer: Point) => {
|
||||
target.value = {
|
||||
x: pointer.x + targetOffset.value.x,
|
||||
y: pointer.y + targetOffset.value.y,
|
||||
}
|
||||
}
|
||||
|
||||
const start = (
|
||||
drag: ActiveInstanceGroupDrag | null,
|
||||
source: InstanceDragData,
|
||||
pointer: Point,
|
||||
) => {
|
||||
clear()
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
if (!drag || drag.instances.length < 2 || reduceMotion) return
|
||||
|
||||
const instanceCards = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-library-instance-card]'),
|
||||
)
|
||||
const sourceCard = instanceCards.find(
|
||||
(card) =>
|
||||
card.dataset.instanceId === source.instanceId &&
|
||||
card.dataset.instanceGroup === source.fromGroup,
|
||||
)
|
||||
|
||||
if (sourceCard) {
|
||||
const sourceRect = sourceCard.getBoundingClientRect()
|
||||
targetOffset.value = {
|
||||
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
|
||||
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
|
||||
}
|
||||
}
|
||||
|
||||
items.value = drag.instances.flatMap(({ instanceId, groupId }) => {
|
||||
const instance = instances.value.find((candidate) => candidate.id === instanceId)
|
||||
const matchingCards = instanceCards.filter(
|
||||
(card) => card.dataset.instanceId === instanceId && card.getClientRects().length > 0,
|
||||
)
|
||||
const card =
|
||||
matchingCards.find((candidate) => candidate.dataset.instanceGroup === groupId) ??
|
||||
matchingCards[0]
|
||||
if (!instance || !card) return []
|
||||
|
||||
const rect = card.getBoundingClientRect()
|
||||
return [
|
||||
{
|
||||
instance,
|
||||
rect: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
updateTarget(pointer)
|
||||
isGathering.value = items.value.length > 0
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
isGathering.value = false
|
||||
clearCleanupTimer()
|
||||
cleanupTimer = window.setTimeout(() => {
|
||||
items.value = []
|
||||
cleanupTimer = undefined
|
||||
}, 400)
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearCleanupTimer)
|
||||
|
||||
return {
|
||||
items,
|
||||
target,
|
||||
isGathering,
|
||||
start,
|
||||
updateTarget,
|
||||
clear,
|
||||
finish,
|
||||
}
|
||||
}
|
||||