Compare commits

..
Author SHA1 Message Date
aecsocket 9a717962fb raw/net estimated revenue fields 2026-08-15 07:10:25 +00:00
aecsocket 30df2ee4dd fix aditude month query 2026-08-14 12:52:23 +00:00
aecsocket a0c2537505 adjust payout report to show actual days 2026-08-14 12:08:38 +00:00
aecsocket ba1b7ce3c1 Merge branch 'main' into boris/dev-1126 2026-08-14 11:13:22 +00:00
aecsocket 5605ee578a wip: payout run reports 2026-08-14 11:09:35 +00:00
aecsocket 8e8640bfb8 add net-60 in-review payout rows 2026-08-04 15:02:24 +01:00
aecsocket 1c3fa44049 fix compile 2026-08-04 14:50:48 +01:00
aecsocket 7cd3e835ab Merge branch 'main' into boris/dev-1126 2026-08-04 14:21:03 +01:00
aecsocket 5c4c30e514 wip 2026-08-04 14:17:50 +01:00
aecsocket 6549d047dc wip: payouts runs migration 2026-07-20 12:50:19 +01:00
1125 changed files with 32208 additions and 88213 deletions
+1 -3
View File
@@ -14,14 +14,12 @@ 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: ${{ github.token }}
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
script: |
const marker = '<!-- changelog -->';
const mergedMarker = '<!-- changelog-merged -->';
+2 -2
View File
@@ -20,8 +20,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
with:
tool: tombi@1.4.1
tool: tombi
- run: tombi lint
- run: tombi fmt --check
+2 -2
View File
@@ -10,7 +10,6 @@ on:
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-deploy.yml'
@@ -125,7 +124,8 @@ jobs:
run: pnpm install
- name: Build frontend
run: pnpm web:build
working-directory: ./apps/frontend
run: pnpm build
env:
CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
-99
View File
@@ -1,99 +0,0 @@
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
+4 -6
View File
@@ -7,7 +7,6 @@ on:
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-preview.yml'
@@ -70,9 +69,10 @@ jobs:
run: pnpm install
- name: Build Storybook
working-directory: ./packages/ui
env:
NODE_OPTIONS: --max-old-space-size=6144
run: pnpm build-storybook
run: pnpm run build-storybook
- name: Configure short SHA
id: meta
@@ -117,8 +117,6 @@ 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
@@ -159,7 +157,7 @@ jobs:
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: fc
with:
token: ${{ github.token }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body-includes: Frontend previews
@@ -167,7 +165,7 @@ jobs:
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
token: ${{ github.token }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body: |
+15 -4
View File
@@ -8,10 +8,6 @@ on:
concurrency:
group: i18n-management
permissions:
contents: write
pull-requests: write
jobs:
pull_translations:
name: 'Pull translations from Crowdin'
@@ -42,17 +38,23 @@ 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
@@ -65,6 +67,12 @@ 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
@@ -108,4 +116,7 @@ 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 }}
-1
View File
@@ -55,7 +55,6 @@ Thumbs.db
# frontend generated files
apps/frontend/src/generated
/packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.ts
.turbo
target
-1
View File
@@ -28,7 +28,6 @@
<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" />
Generated
+23 -12
View File
@@ -2223,16 +2223,6 @@ 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"
@@ -2676,10 +2666,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",
@@ -5490,7 +5480,6 @@ dependencies = [
"clickhouse",
"color-eyre",
"color-thief",
"component-derive",
"const_format",
"dashmap",
"derive_more 2.1.1",
@@ -5568,6 +5557,16 @@ 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"
@@ -9433,6 +9432,18 @@ 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"
+12 -5
View File
@@ -7,8 +7,8 @@ members = [
"apps/labrinth",
"packages/app-lib",
"packages/ariadne",
"packages/component-derive",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-content-management",
"packages/modrinth-log",
"packages/modrinth-maxmind",
@@ -42,7 +42,9 @@ 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 = [
@@ -59,12 +61,13 @@ 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"
@@ -159,7 +162,10 @@ 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",
@@ -185,6 +191,7 @@ 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"
+7 -10
View File
@@ -6,16 +6,13 @@ extend-exclude = [
"packages/utils/",
"packages/ui/",
"packages/blog/",
]
[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",
# 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-words]
+1 -3
View File
@@ -14,7 +14,6 @@
"test": "vue-tsc --noEmit"
},
"dependencies": {
"@dnd-kit/vue": "^0.5.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
@@ -32,13 +31,13 @@
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.43.5",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"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",
@@ -46,7 +45,6 @@
"vue-i18n": "^10.0.0",
"vue-router": "^4.6.0",
"vue-virtual-scroller": "v2.0.0-beta.8",
"vue3-ace-editor": "^2.2.4",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
File diff suppressed because it is too large Load Diff
@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

@@ -55,10 +55,6 @@
);
}
html.light-mode {
color-scheme: light;
}
body {
position: fixed;
width: 100%;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

@@ -0,0 +1,360 @@
<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>
@@ -0,0 +1,371 @@
<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>
@@ -34,7 +34,7 @@
</div>
</div>
</template>
<div class="bg-button-bg pt-1 pb-2 border-0 border-t border-solid border-surface-5">
<div class="bg-button-bg pt-1 pb-2 border border-solid border-surface-5">
<template v-if="accounts.length > 0">
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
<button
@@ -107,7 +107,6 @@ 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,
@@ -119,6 +118,7 @@ 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,7 +185,6 @@ 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,7 +150,6 @@ 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'
@@ -353,16 +352,14 @@ function getDisplayIconUrl(icon: string | null | undefined): string | null {
return convertFileSrc(icon)
}
function getNotification(): PopupNotificationStandard | null {
function getNotification(): PopupNotification | null {
if (!notificationId.value) {
return null
}
const notification = popupNotificationManager
.getNotifications()
.find((notification) => notification.id === notificationId.value)
return notification?.contentType === 'standard' && notification.type === 'download'
? notification
: null
return notification ?? null
}
function removeNotification(): void {
@@ -380,17 +377,11 @@ function syncTerminalNotifications(): void {
for (const terminal of terminalNotifications) {
const popupId = terminalNotificationIds.get(terminal.id)
let notification = popupId
? popupNotificationManager
.getNotifications()
.find(
(candidate): candidate is PopupNotificationStandard =>
candidate.id === popupId && candidate.contentType === 'standard',
)
? popupNotificationManager.getNotifications().find((candidate) => candidate.id === popupId)
: undefined
if (!notification) {
notification = popupNotificationManager.addPopupNotification({
contentType: 'standard',
title: terminal.title,
text: terminal.text,
type: terminal.type,
@@ -420,7 +411,7 @@ function syncTerminalNotifications(): void {
function buildDownloadItems(): PopupNotificationProgressItem[] {
return [
...installJobNotifications.progressItems.value,
...currentLoadingBars.value.map<PopupNotificationProgressItem>((bar) => ({
...currentLoadingBars.value.map((bar) => ({
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
@@ -460,7 +451,7 @@ function updateNotification(resummon = false): void {
return
}
const notif = getNotification()
let notif = getNotification()
const progressItems = buildDownloadItems()
if (notif) {
@@ -472,8 +463,7 @@ function updateNotification(resummon = false): void {
notif.progress = undefined
notif.waiting = undefined
} else {
const notification = popupNotificationManager.addPopupNotification({
contentType: 'standard',
notif = popupNotificationManager.addPopupNotification({
title: installJobNotifications.active.value
? installJobNotifications.title.value
: formatMessage(messages.downloads),
@@ -481,7 +471,7 @@ function updateNotification(resummon = false): void {
autoCloseMs: null,
progressItems,
})
notificationId.value = notification.id
notificationId.value = notif.id
}
}
@@ -558,7 +548,7 @@ async function refreshLoadingBars() {
const installJobNotifications = await useInstallJobNotifications({
router,
handleError: (error) => handleError(toError(error)),
handleError,
onChange: updateNotification,
})
@@ -0,0 +1,181 @@
<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>
@@ -3,7 +3,6 @@ import {
CheckIcon,
CopyIcon,
DropdownIcon,
FolderOpenIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
@@ -14,31 +13,20 @@ import {
Button,
ButtonLink,
Collapsible,
defineMessages,
IconButton,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
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 { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
openBackupsFolder: {
id: 'app.error.state-init.open-backups-folder',
defaultMessage: 'Open backups folder',
},
})
const errorModal = ref()
const error = ref()
@@ -135,10 +123,6 @@ function retryDirectoryChange() {
window.location.reload()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
const loadingRepair = ref(false)
async function repairInstance() {
loadingRepair.value = true
@@ -326,10 +310,6 @@ async function copyToClipboard(text) {
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
>
{{ debugInfo }}
<button class="btn" @click="openDbBackupsFolder">
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(messages.openBackupsFolder) }}
</button>
</div>
<IconButton
v-tooltip="'Copy debug info'"
@@ -7,9 +7,8 @@ import {
FileTreeSelect,
injectNotificationManager,
injectPopupNotificationManager,
Input,
NewModal,
Textarea,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
@@ -195,7 +194,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>
<Input
<StyledInput
v-model="nameInput"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
@@ -207,7 +206,7 @@ function normalizeExportPath(path) {
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<Input
<StyledInput
v-model="versionInput"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
@@ -220,8 +219,9 @@ function normalizeExportPath(path) {
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<Textarea
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
@@ -7,39 +7,22 @@ import {
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import {
Avatar,
defineMessages,
IconButton,
injectNotificationManager,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
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 { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { 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: {
@@ -176,10 +159,9 @@ onMounted(() => {
>
<Avatar
size="48px"
:src="getInstanceIconUrl(instance.icon_path)"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.id"
:alt="formatMessage(messages.instanceIcon)"
pad-transparent-corners
alt="Mod card"
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span>
@@ -187,10 +169,10 @@ onMounted(() => {
<div class="flex items-center">
<IconButton
v-if="playing"
v-tooltip="formatMessage(messages.stop)"
v-tooltip="'Stop'"
type="colored"
color="red"
:label="formatMessage(messages.stop)"
:label="'Stop'"
@mouseenter="checkProcess"
@click="(e) => stop(e, 'InstanceCard')"
>
@@ -198,18 +180,18 @@ onMounted(() => {
</IconButton>
<IconButton
v-else-if="modLoading"
v-tooltip="formatMessage(messages.loading)"
:label="formatMessage(messages.loading)"
v-tooltip="'Instance is loading...'"
:label="'Instance is loading...'"
disabled
>
<SpinnerIcon class="animate-spin" />
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="formatMessage(messages.play)"
v-tooltip="'Play'"
:type="first ? 'colored' : 'base'"
:color="first ? 'brand' : undefined"
:label="formatMessage(messages.play)"
label="Play"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
@@ -221,13 +203,9 @@ onMounted(() => {
<TimerIcon />
<span class="text-sm">
<template v-if="instance.last_played">
{{
formatMessage(messages.played, {
relativeTime: formatRelativeTime(dayjs(instance.last_played).toISOString()),
})
}}
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</template>
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
<template v-else> Never played </template>
</span>
</div>
</div>
@@ -241,20 +219,19 @@ onMounted(() => {
<div class="relative flex items-center justify-center">
<Avatar
size="48px"
:src="getInstanceIconUrl(instance.icon_path)"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.id"
:alt="formatMessage(messages.instanceIcon)"
alt="Mod card"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
pad-transparent-corners
/>
<div class="absolute inset-0 flex items-center justify-center">
<IconButton
v-if="playing"
v-tooltip="formatMessage(messages.stop)"
v-tooltip="'Stop'"
type="colored"
color="red"
size="xl"
:label="formatMessage(messages.stop)"
:label="'Stop'"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@@ -264,17 +241,17 @@ onMounted(() => {
</IconButton>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<IconButton
v-else-if="!installed && !instance.quarantined"
v-tooltip="formatMessage(messages.repair)"
v-tooltip="'Repair'"
type="colored"
color="brand"
size="xl"
:label="formatMessage(messages.repair)"
:label="'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)"
>
@@ -282,11 +259,11 @@ onMounted(() => {
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="formatMessage(messages.play)"
v-tooltip="'Play'"
type="colored"
color="brand"
size="xl"
:label="formatMessage(messages.play)"
:label="'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,18 +1,9 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonLink, defineMessages, FormattedTag, useVIntl } from '@modrinth/ui'
import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
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
@@ -41,10 +32,9 @@ const instanceLink = computed(() => {
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
<span class="flex items-center gap-2">
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:alt="instance.name"
size="48px"
pad-transparent-corners
/>
<span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast">
@@ -58,9 +48,7 @@ const instanceLink = computed(() => {
</span>
</span>
</router-link>
<ButtonLink :to="instanceLink">
<LeftArrowIcon /> {{ formatMessage(messages.backToInstance) }}
</ButtonLink>
<ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </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">
<Input
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
@@ -98,7 +98,13 @@ import {
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, Input, useVIntl } from '@modrinth/ui'
import {
Button,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -1,36 +1,22 @@
<script setup>
import { EyeIcon, FolderOpenIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import {
Avatar,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
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 { useRouter } from 'vue-router'
import NavButton from '@/components/ui/NavButton.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { getInstanceIconUrl, kill, list, run } from '@/helpers/instance'
import { get_all } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils'
import { list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
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 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 STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
const instanceOptions = ref()
const runningInstances = ref([])
const { formatMessage } = useVIntl()
@@ -168,11 +154,8 @@ useAppEvent('instance', async (event) => {
}
})
useAppEvent('process', checkProcesses)
onMounted(() => {
window.addEventListener('resize', updateMaxAuto)
checkProcesses()
})
onUnmounted(() => {
@@ -190,18 +173,6 @@ const messages = defineMessages({
id: 'app.quick-instance-switcher.drag-show-tooltip',
defaultMessage: 'Drag to show recent instances',
},
viewInstance: {
id: 'app.quick-instance-switcher.view-instance',
defaultMessage: 'View instance',
},
instanceActions: {
id: 'app.quick-instance-switcher.actions.label',
defaultMessage: 'Instance actions',
},
instanceLocked: {
id: 'app.quick-instance-switcher.instance-locked',
defaultMessage: 'This instance has been locked',
},
})
const dividerTooltip = computed(() => {
@@ -210,69 +181,6 @@ const dividerTooltip = computed(() => {
}
return formatMessage(visibleCount.value === 0 ? messages.dragShowTooltip : messages.dragTooltip)
})
async function checkProcesses() {
const processes = (await get_all().catch(handleError)) ?? []
runningInstances.value = processes.map((process) => process.instance_id)
}
async function playInstance(instance) {
if (instance.quarantined || instance.install_stage !== 'installed') return
await run(instance.id)
.catch((err) => handleSevereError(err, { instanceId: instance.id }))
.finally(() => {
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'QuickInstanceSwitcher',
})
})
}
async function stopInstance(instance) {
await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'QuickInstanceSwitcher',
})
}
function openContextMenu(event, instance) {
const playing = runningInstances.value.includes(instance.id)
instanceOptions.value?.open(event, [
playing
? {
id: 'stop',
label: formatMessage(commonMessages.stopButton),
icon: StopCircleIcon,
tone: 'red',
action: () => stopInstance(instance),
}
: {
id: 'play',
label: formatMessage(commonMessages.playButton),
icon: PlayIcon,
tone: 'brand',
disabled: instance.quarantined || instance.install_stage !== 'installed',
tooltip: instance.quarantined ? formatMessage(messages.instanceLocked) : undefined,
action: () => playInstance(instance),
},
{ type: 'divider' },
{
id: 'open-instance',
label: formatMessage(messages.viewInstance),
icon: EyeIcon,
action: () => router.push(`/instance/${encodeURIComponent(instance.id)}`),
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
icon: FolderOpenIcon,
action: () => showInstanceInFolder(instance.id),
},
])
}
</script>
<template>
@@ -290,15 +198,13 @@ function openContextMenu(event, instance) {
:key="instance.id"
v-tooltip.right="instance.name"
class="quick-instance-item"
@contextmenu.prevent.stop="(event) => openContextMenu(event, instance)"
>
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
pad-transparent-corners
/>
<div
v-if="instance.install_stage !== 'installed'"
@@ -309,7 +215,6 @@ function openContextMenu(event, instance) {
</NavButton>
</div>
</TransitionGroup>
<ContextMenu ref="instanceOptions" :label="formatMessage(messages.instanceActions)" />
<div
v-tooltip.right="dividerTooltip"
class="flex items-center justify-center py-2 select-none"
@@ -1,6 +1,6 @@
<template>
<Transition name="splash-fade" @after-leave="onAfterLeave">
<div v-if="!doneLoading" class="splash-screen" :class="`${theme.active}-mode`">
<div v-if="!doneLoading" class="splash-screen dark">
<div class="app-logo-wrapper" data-tauri-drag-region>
<svg
class="app-logo"
@@ -83,9 +83,6 @@ import { ref, watch } from 'vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { useTheme } from '@/composables/use-theme.ts'
const theme = useTheme()
const doneLoading = ref(false)
const loadingProgress = ref(0)
@@ -148,12 +145,6 @@ useAppEvent('loading', (e) => {
position: fixed;
inset: 0;
z-index: 10000;
--splash-cube-image: url('@/assets/loading/cube.png');
&.light-mode {
--splash-cube-image: url('@/assets/loading/cube-light.webp');
}
}
.splash-fade-leave-active {
@@ -175,7 +166,6 @@ useAppEvent('loading', (e) => {
align-items: center;
gap: 1rem;
color: var(--color-contrast);
z-index: 9998;
}
@@ -194,8 +184,8 @@ useAppEvent('loading', (e) => {
height: 100vh;
width: 100vw;
background:
linear-gradient(180deg, var(--splash-tint-top) 0%, var(--splash-tint-bottom) 97.29%),
linear-gradient(0deg, var(--splash-overlay), var(--splash-overlay));
linear-gradient(180deg, rgba(66, 131, 92, 0.275) 0%, rgba(17, 35, 43, 0.5) 97.29%),
linear-gradient(0deg, rgba(22, 24, 28, 0.64), rgba(22, 24, 28, 0.64));
z-index: 9997;
}
@@ -208,19 +198,11 @@ useAppEvent('loading', (e) => {
width: 180vw;
height: 180vh;
background-color: var(--color-bg);
opacity: 0.8;
background: #16181c url('@/assets/loading/cube.png') center no-repeat;
background-size: contain;
z-index: 9996;
&::after {
content: '';
position: absolute;
inset: 0;
background: var(--splash-cube-image) center no-repeat;
background-size: contain;
opacity: var(--splash-cube-opacity);
mix-blend-mode: var(--splash-cube-blend);
}
}
.base-bg {
@@ -1,154 +0,0 @@
<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>
@@ -1,38 +1,35 @@
<template>
<Teleport to="body">
<section
v-if="showControls"
ref="controlsEl"
class="pointer-events-auto fixed top-0 right-0 z-[10001] flex h-12 items-center gap-2 bg-bg-raised rounded-bl-2xl px-1.5"
data-tauri-drag-region-exclude
<section
v-if="showControls"
class="flex items-center gap-2 mr-1.5"
data-tauri-drag-region-exclude
>
<IconButton
type="quiet"
label="Minimize window"
class="relative expanded-button"
@click="() => getCurrentWindow().minimize()"
>
<IconButton
type="quiet"
label="Minimize window"
class="relative expanded-button"
@click="getCurrentWindow().minimize()"
>
<MinimizeIcon />
</IconButton>
<IconButton
type="quiet"
label="Toggle maximize window"
class="relative expanded-button"
@click="getCurrentWindow().toggleMaximize()"
>
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</IconButton>
<IconButton
type="quiet"
label="Close window"
class="relative expanded-button close-button"
@click="handleClose"
>
<XIcon />
</IconButton>
</section>
</Teleport>
<MinimizeIcon />
</IconButton>
<IconButton
type="quiet"
label="Toggle maximize window"
class="relative expanded-button"
@click="() => getCurrentWindow().toggleMaximize()"
>
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</IconButton>
<IconButton
type="quiet"
label="Close window"
class="relative expanded-button close-button"
@click="handleClose"
>
<XIcon />
</IconButton>
</section>
</template>
<script setup>
@@ -40,20 +37,19 @@ import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets
import { IconButton } from '@modrinth/ui'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
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 appSettings = useAppSettings()
const themeStore = useTheming()
const nativeDecorations = ref(true)
const isMaximized = ref(false)
const os = ref('')
const controlsEl = ref(null)
const alwaysShowAppControls = computed(() => appSettings.getFeatureFlag('always_show_app_controls'))
const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
const showControls = computed(
() =>
@@ -61,26 +57,6 @@ const showControls = computed(
(!nativeDecorations.value && (os.value === 'Windows' || os.value === 'Linux')),
)
function setWindowControlsWidth(width) {
document.documentElement.style.setProperty('--window-controls-width', `${width}px`)
}
let resizeObserver
watch(controlsEl, (el) => {
resizeObserver?.disconnect()
resizeObserver = undefined
if (!el) {
setWindowControlsWidth(0)
return
}
resizeObserver = new ResizeObserver(() => {
setWindowControlsWidth(el.getBoundingClientRect().width)
})
resizeObserver.observe(el)
setWindowControlsWidth(el.getBoundingClientRect().width)
})
onMounted(async () => {
os.value = await getOS()
@@ -102,11 +78,6 @@ onMounted(async () => {
})
})
onUnmounted(() => {
resizeObserver?.disconnect()
document.documentElement.style.removeProperty('--window-controls-width')
})
const handleClose = async () => {
await saveWindowState(StateFlags.ALL)
await getCurrentWindow().close()
@@ -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-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="!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="{
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
@@ -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 appSettings = useAppSettings()
const themeStore = useTheming()
const props = defineProps<{
credentials: ModrinthCredentials | null
@@ -39,11 +39,11 @@ type FriendsSectionCollapsedFlag =
| 'friends_pending_collapsed'
function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
return appSettings.getFeatureFlag(flag)
return themeStore.getFeatureFlag(flag)
}
function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
appSettings.featureFlags[flag] = collapsed
themeStore.featureFlags[flag] = collapsed
getSettings()
.then((settings) => {
settings.feature_flags[flag] = collapsed
@@ -122,11 +122,6 @@ function addFriendFromModal() {
username.value = ''
}
function showAddFriendModal() {
username.value = ''
addFriendModal.value?.show()
}
function addFriend(friend: FriendWithUserData) {
acceptFriend(friend)
}
@@ -135,8 +130,6 @@ function removeFriend(friend: FriendWithUserData) {
removeFriendRecord(friend)
}
defineExpose({ showAddFriendModal })
const messages = defineMessages({
addFriend: {
id: 'friends.action.add-friend',
@@ -257,7 +250,7 @@ const messages = defineMessages({
{{ formatMessage(messages.usernameDescription) }}
</p>
<div class="flex items-center gap-2 mt-4">
<Input
<StyledInput
v-model="username"
:icon="UserIcon"
type="text"
@@ -287,15 +280,14 @@ const messages = defineMessages({
>
<UserPlusIcon />
</IconButton>
<Input
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
appearance="transparent"
:placeholder="formatMessage(messages.searchFriends)"
clearable
input-class="!text-primary !placeholder:text-primary"
wrapper-class="flex-1 !border-button-bg [&>span:first-child]:!text-primary [&>span:first-child]:!opacity-100"
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
@keyup.esc="search = ''"
/>
</template>
@@ -1,18 +1,17 @@
<script setup lang="ts">
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import type { ButtonMenuOption } from '@modrinth/ui'
import {
Accordion,
ContextMenu,
Avatar,
defineMessages,
IconButton,
TeleportOverflowMenu,
UserAvatar,
useVIntl,
} from '@modrinth/ui'
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
@@ -37,30 +36,27 @@ const emit = defineEmits<{
onClose: []
}>()
function createContextMenuOptions(friend: FriendWithUserData): ButtonMenuOption[] {
return [
{
id: 'view-profile',
label: formatMessage(messages.viewProfile),
icon: UserIcon,
action: () => openProfile(friend.username),
},
friend.accepted
? {
id: 'remove-friend',
label: formatMessage(messages.removeFriend),
icon: TrashIcon,
tone: 'red',
hoverFilledOnly: true,
action: () => void props.removeFriend(friend),
}
: {
id: 'cancel-request',
label: formatMessage(messages.cancelRequest),
icon: XIcon,
action: () => void props.removeFriend(friend),
},
]
function createContextMenuOptions(friend: FriendWithUserData) {
if (friend.accepted) {
return [
{
name: 'view-profile',
},
{
name: 'remove-friend',
color: 'danger',
},
]
} else {
return [
{
name: 'view-profile',
},
{
name: 'cancel-request',
},
]
}
}
function openProfile(username: string) {
@@ -68,6 +64,16 @@ function openProfile(username: string) {
}
const friendOptions = useTemplateRef('friendOptions')
async function handleFriendOptions(args: { item: FriendWithUserData; option: string }) {
switch (args.option) {
case 'remove-friend':
case 'cancel-request':
await props.removeFriend(args.item)
break
case 'view-profile':
openProfile(args.item.username)
}
}
const messages = defineMessages({
removeFriend: {
@@ -90,15 +96,18 @@ const messages = defineMessages({
id: 'friends.friend.view-profile',
defaultMessage: 'View profile',
},
friendActionsLabel: {
id: 'friends.friend.actions.label',
defaultMessage: 'Friend actions',
},
})
</script>
<template>
<ContextMenu ref="friendOptions" :label="formatMessage(messages.friendActionsLabel)" />
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend> <TrashIcon /> {{ formatMessage(messages.removeFriend) }} </template>
<template #cancel-request> <XIcon /> {{ formatMessage(messages.cancelRequest) }} </template>
</ContextMenu>
<Accordion
:open-by-default="openByDefault"
:force-open="isSearching"
@@ -121,24 +130,32 @@ const messages = defineMessages({
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1 select-none"
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
@contextmenu.prevent.stop="
(event) => friendOptions?.open(event, createContextMenuOptions(friend))
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
"
>
<RouterLink
:to="`/user/${encodeURIComponent(friend.username)}`"
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline group no-click-animation"
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
>
<UserAvatar
:src="friend.avatar"
size="32px"
:badge="friend.online"
:grayscale="!friend.online && friend.accepted"
/>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0 group-hover:underline"
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
@@ -1,40 +1,17 @@
<script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
Button,
defineMessages,
injectNotificationManager,
Input,
useVIntl,
} from '@modrinth/ui'
import { Admonition, Avatar, Button, injectNotificationManager, StyledInput } 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 { getInstanceIconUrl, list } from '@/helpers/instance'
import { 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()
@@ -100,14 +77,14 @@ async function addServer(instance) {
</script>
<template>
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
<ModalWrapper ref="modal" header="Add server to instance">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition type="warning" :body="formatMessage(messages.compatibilityWarning)" />
<Input
<Admonition type="warning" body="This server may not be compatible with all instances." />
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="search"
:placeholder="formatMessage(messages.searchPlaceholder)"
placeholder="Search for an instance"
autocomplete="off"
/>
<div class="max-h-[21rem] overflow-y-auto">
@@ -122,25 +99,20 @@ async function addServer(instance) {
@click="modal.hide()"
>
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
class="mr-2 [--size:2rem]"
pad-transparent-corners
/>
{{ 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" />
{{
formatMessage(
instance.adding ? messages.adding : instance.added ? messages.added : messages.add,
)
}}
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
</Button>
</div>
</div>
<div class="input-group push-right">
<Button @click="modal.hide()">{{ formatMessage(messages.cancel) }}</Button>
<Button @click="modal.hide()">Cancel</Button>
</div>
</div>
</ModalWrapper>
@@ -13,10 +13,11 @@
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 appSettings = useAppSettings()
const themeStore = useTheming()
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const modal = useTemplateRef('modal')
@@ -33,7 +34,7 @@ function show(
fileName.value = selectedFileName
externalFilesInModpack.value = selectedExternalFiles
if (appSettings.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
void createInstance()
return
}
@@ -49,7 +50,7 @@ function reset() {
async function proceed(dontShowAgain: boolean) {
if (dontShowAgain) {
appSettings.featureFlags[skipUnknownPackWarningFeatureFlag] = true
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
const settings = await getSettings()
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
await setSettings(settings)
@@ -1,515 +0,0 @@
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
@@ -1,540 +0,0 @@
<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>
@@ -1,206 +0,0 @@
<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>
@@ -1,162 +0,0 @@
<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"
pad-transparent-corners
/>
<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>
@@ -1,398 +0,0 @@
<script setup lang="ts">
import { StarIcon } from '@modrinth/assets'
import { ContextMenu, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
import Draggable from 'vuedraggable'
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.',
},
instanceActionsLabel: {
id: 'app.library.instance.actions.label',
defaultMessage: 'Instance actions',
},
})
const {
instanceGroups,
libraryGroupsLoaded,
isSearching,
displayState,
filters,
reorderingGroups,
reorderGroups,
instanceOptions,
confirmDeleteModal,
iconEditorModal,
currentIconEditorInstance,
currentDeleteInstances,
clearLibraryInstanceSelection,
deleteInstance,
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" :label="formatMessage(messages.instanceActionsLabel)">
<template #remove_from_favorites="{ option }">
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" />
{{ option.label }}
</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>
@@ -1,99 +0,0 @@
<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>
@@ -1,64 +0,0 @@
<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>
@@ -1,106 +0,0 @@
<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>
@@ -1,553 +0,0 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import {
DropdownIcon,
EditIcon,
PlusIcon,
SquarePlusIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Accordion,
Button,
commonMessages,
ContextMenu,
defineMessages,
injectNotificationManager,
InlineEditableText,
NewModal,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from '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.',
},
groupActionsLabel: {
id: 'app.library.group.actions.label',
defaultMessage: 'Group actions',
},
})
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?.open(event, [
{
id: 'new_instance',
label: formatMessage(messages.newInstance),
icon: PlusIcon,
action: () => showCreationModal?.(),
},
{
id: 'add_instances',
label: formatMessage(messages.addToGroup),
icon: SquarePlusIcon,
shown: isCustomGroup.value,
action: () => openGroupInstancesModal(props.instanceGroup.id),
},
{
id: 'edit_name',
label: formatMessage(messages.editGroupName),
icon: EditIcon,
shown: isCustomGroup.value,
action: () => void groupNameInput.value?.startEditing(),
},
{ type: 'divider', shown: isCustomGroup.value },
{
id: 'delete_group',
label: formatMessage(messages.deleteGroup),
icon: TrashIcon,
shown: isCustomGroup.value,
tone: 'red',
hoverFilledOnly: true,
action: requestGroupDeletion,
},
])
}
function prepareGroupToggle(event: PointerEvent) {
const editor = groupNameInput.value
const contextMenuWasOpen = groupContextMenuOpen.value
if (contextMenuWasOpen) {
groupOptions.value?.close()
}
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"
:label="formatMessage(messages.groupActionsLabel)"
@close="groupContextMenuOpen = false"
/>
<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>
@@ -1,81 +0,0 @@
<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
pad-transparent-corners
/>
<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>
@@ -1,389 +0,0 @@
<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,
get playing() {
return playing.value
},
})
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>
@@ -1,236 +0,0 @@
<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>
@@ -1,130 +0,0 @@
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,
}
}
@@ -1,70 +0,0 @@
<script setup lang="ts">
import {
defineMessages,
DropdownFilterBar,
type DropdownFilterBarCategory,
formatLoader,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import { useLibrary } from '@/components/ui/library/use-library'
const { filters, instances } = useLibrary()
const { formatMessage } = useVIntl()
const messages = defineMessages({
instanceType: { id: 'app.library.filter.instance-type', defaultMessage: 'Instance type' },
modpack: { id: 'app.library.filter.instance-type.modpack', defaultMessage: 'Modpack' },
server: { id: 'app.library.filter.instance-type.server', defaultMessage: 'Server' },
custom: { id: 'app.library.filter.instance-type.custom', defaultMessage: 'Custom' },
gameVersion: { id: 'app.library.filter.game-version', defaultMessage: 'Game version' },
loader: { id: 'app.library.filter.loader', defaultMessage: 'Loader' },
filterBy: { id: 'app.library.filter.label', defaultMessage: 'Filter by' },
addFilter: { id: 'app.library.filter.add', defaultMessage: 'Add filter' },
clearFilters: { id: 'app.library.filter.clear', defaultMessage: 'Clear filters' },
})
const filterCategories = computed<DropdownFilterBarCategory[]>(() => [
{
key: 'instanceType',
label: formatMessage(messages.instanceType),
options: [
{ value: 'modpack', label: formatMessage(messages.modpack) },
{ value: 'server', label: formatMessage(messages.server) },
{ value: 'custom', label: formatMessage(messages.custom) },
],
},
{
key: 'gameVersion',
label: formatMessage(messages.gameVersion),
searchable: true,
options: [...new Set(instances.value.map((instance) => instance.game_version))]
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
.map((version) => ({ value: version, label: version })),
},
{
key: 'loader',
label: formatMessage(messages.loader),
options: [...new Set(instances.value.map((instance) => instance.loader))]
.map((loader) => ({
value: loader,
label: formatLoader(formatMessage, loader),
}))
.sort((a, b) => a.label.localeCompare(b.label)),
},
])
</script>
<template>
<DropdownFilterBar
v-model="filters"
:categories="filterCategories"
use-filter-icon
:label="formatMessage(messages.filterBy)"
:add-label="formatMessage(messages.addFilter)"
:clear-label="formatMessage(messages.clearFilters)"
apply-immediately
checkbox-position="right"
/>
</template>
@@ -1,56 +0,0 @@
<script setup lang="ts">
import { PlusIcon, SearchIcon, SquarePlusIcon } from '@modrinth/assets'
import { Button, defineMessages, Input, useVIntl } from '@modrinth/ui'
import { computed, inject } from 'vue'
import FilterMenu from '@/components/ui/library/library-toolbar/filter-menu.vue'
import NewGroupModal from '@/components/ui/library/library-toolbar/new-group-modal.vue'
import SortMenu from '@/components/ui/library/library-toolbar/sort-menu.vue'
import { useLibrary } from '@/components/ui/library/use-library'
const { search, selectedLibraryInstances, openNewGroupModal } = useLibrary()
const showCreationModal = inject<() => void>('showCreationModal')
const { formatMessage } = useVIntl()
const messages = defineMessages({
search: { id: 'app.library.search.placeholder', defaultMessage: 'Search' },
newGroup: { id: 'app.library.group.new', defaultMessage: 'New group' },
newInstance: { id: 'app.library.instance.new', defaultMessage: 'New instance' },
})
const selectedInstanceIds = computed(
() =>
new Set([...selectedLibraryInstances.value.values()].map((selection) => selection.instanceId)),
)
function openNewGroup() {
openNewGroupModal(selectedInstanceIds.value)
}
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<Input
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="min-w-[16rem] flex-1"
/>
<Button @click="openNewGroup">
<SquarePlusIcon />
{{ formatMessage(messages.newGroup) }}
</Button>
<Button type="colored" color="brand" @click="showCreationModal?.()">
<PlusIcon />
{{ formatMessage(messages.newInstance) }}
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<SortMenu />
<div class="mx-2 h-6 w-px bg-surface-5" />
<FilterMenu />
</div>
</div>
<NewGroupModal />
</template>

Some files were not shown because too many files have changed in this diff Show More