Compare commits

..
Author SHA1 Message Date
aecsocket e9f245cacd impl url parsing stuff 2026-08-26 14:32:56 +01:00
aecsocket 641ad9942a cel #bind directive 2026-08-26 13:29:21 +01:00
aecsocket 58f89d9940 prepr 2026-08-25 17:06:31 +01:00
aecsocket 30477f3316 simple autocomplete 2026-08-25 17:03:06 +01:00
aecsocket a7196deb98 cel #define preprocessor, regex ext 2026-08-25 13:57:14 +01:00
aecsocket 9f28a9ba5f enable cel regex 2026-08-25 13:28:42 +01:00
aecsocket b5b7e9bc42 allow modifying test trace in rules.vue 2026-08-25 13:16:59 +01:00
aecsocket 0af562ef6f Merge branch 'main' into boris/dev-1205-trace-rules 2026-08-22 18:23:19 +09:00
aecsocket e9200c7483 update frontend 2026-08-22 18:23:13 +09:00
ArthurandProspector fe9ae94a5d feat: add project_type filter to random projects endpoint (#6687)
* Fix random project being stale, add project_type filter

* Avoid full scan

* Add comment

* Run prepare, fix v2

* Trigger ci recheck

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-21 21:23:29 +00:00
Sychic 871a161327 feat(app-lib): send file hash (#7266) 2026-08-21 19:40:16 +00:00
Sychic 797ec2ec3c fix(labrinth): disclosure visibility (#7265) 2026-08-21 18:30:11 +00:00
Prospector 237963bb35 fix error thrown when update happens (#7256) 2026-08-20 22:57:51 +00:00
Truman Gao ba667921d1 fix: resize jump in section handle only showing on hover (#7255)
fix: resize handle only showing on hover
2026-08-20 22:46:29 +00:00
Prospector 56dba8ccc2 changelog 2026-08-20 15:10:17 -07:00
Prospector 82d0184a85 in mrpack export, match with hash rather than by primary on version (#7254) 2026-08-20 15:09:58 -07:00
Michael H. 5f9522b5cf fix: actually run in the right directory 2026-08-21 00:00:56 +02:00
Michael H. 75d2109be8 fix: typo 2026-08-21 00:00:04 +02:00
Michael H. 7e611c7678 fix: frontend docker build 2026-08-20 23:57:28 +02:00
Prospector 0bd341371a changelog 2026-08-20 14:46:59 -07:00
Prospector 32a43f2a22 fix changelog collect 2026-08-20 14:46:54 -07:00
Prospector 2909aa49ca fix showing installed for other projects (#7253) 2026-08-20 14:43:52 -07:00
Prospector 816830eea3 allow for more quick instances now that there is more height (#7252) 2026-08-20 14:43:39 -07:00
Prospector 087752312b fix navtabs issues and unify project lists (#7245)
* fix navtabs issues and unify project lists

* move page navtab logic to component
2026-08-20 21:32:32 +00:00
JeffandProspector bcd7133251 feat: pagination go to button (#7158)
* feat: paginator go to button

- Added a way in the paginator to select the page number you want to go to instead of clicking the arrow's.

* localization

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-20 20:12:21 +00:00
Truman Gao a85b8eed3f fix: app library post release (#7211)
* fix: fixed height on modal

* fix: conditional auto padding being applied on user avatars

* feat: allow ungrouped to be dragged

* feat: add drag handle to resize num items in jump in

* qa

* feat: add context menu submenus

* feat: hook up icon editing in sub menu

* fix: breadcrumb and sidebar not matching

* fix: bring back signal icon for ping and players online

* feat: add compact mode for library

* smaller checkmark

* pnpm prepr

* feat: bring back loader/game version sort

* add loader symbols

* refactor: compact mode with sync'd app settings

* pnpm prepr

* move loaders up

* qa

* feat: exclude loaders from randomizing
2026-08-20 19:30:42 +00:00
Michael H. 52e055b20f build: only build frontend image on prod 2026-08-20 21:16:52 +02:00
5181370293 feat: depends on search filter (#6713)
* feat: add norisk client source support (#6707)

Signed-off-by: Robin <me@orb1n.dev>

* changelog

* feat: implement depends on search filter

* feat: add modpack "includes content" filter and update depends on

* feat: style pass

* feat: show included modpack projects filter tag better

* pnpm prepr

* feat: polish depends on filter

* fix: clear button, page scroll, and metadata loading state

* feat: hook up excludes modpack

* fix: combobox closing race

* Revert "fix: combobox closing race"

This reverts commit e51529ca65.

* fix scrolling after applying depends on

* feedback

* pnpm prepr

* slightly make buttons larger for touch devices

* feat: add view dependents/view modpacks in project overflow menu

* pnpm prepr

* fix: divider

* add icon

* fix: project search combobox background in app

---------

Signed-off-by: Robin <me@orb1n.dev>
Co-authored-by: Robin <robin.murer11@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-20 19:01:17 +00:00
exefer 5d54cfa0dc refactor(daedalus_client): replace serde-xml-rs with quick-xml (#7185)
quick-xml is already the de facto XML (de)serializer used
elsewhere in the workspace (labrinth, app-lib), so consolidate
on it here too. Its serde feature deserializes the existing
maven-metadata.xml structs without any changes. Drops one
redundant dependency from the workspace.
2026-08-20 19:00:36 +00:00
Blodhgarm 5eb64cea3e feat: Add download link inside managed content modal for Reports (#7205)
* feat: Add download link inside managed content modal

* fix: add missing localization for download file btn added
2026-08-20 18:47:38 +00:00
chyz 6670e3184e fix: buttonlinks not being real links + dropdowns not aligning correctly (#7206)
* fix some ButtonLinks not being links

* fix comboboxes not aligning correctly immediately

* prepr
2026-08-20 18:47:05 +00:00
DeDiamondPro 12928edbcb feat(labrinth): add Resourcify and OneClient download source (#7217)
feat: add Resourcify and OneClient user agents
2026-08-20 18:07:08 +00:00
wal c94034f4e2 fix(app): clean up online/offline listeners on Browse unmount (#7213)
fix(app): clean up online/offline listeners on Browse.vue unmount

Browse.vue registers window 'online'/'offline' listeners but never
removes them. Since only LibraryPage is kept alive by the router,
Browse unmounts every time the user navigates away (e.g. into a
project) and remounts on return, leaking a new pair of closures (and
pinning the whole component scope) on window each time. Over a
browsing session this accumulates continuously.
2026-08-20 18:06:36 +00:00
ItsCapnMurica 8eb12d5d24 fix: encode project slugs in canonical URLs (#7208) 2026-08-20 17:51:47 +00:00
Sychic 6a92ab0c2c fix(labrinth): return last file if no primary is found (#6946)
* fix(labrinth): return last file if no primary is found

* style(labrinth): clippy
2026-08-20 17:51:08 +00:00
Michael H. d74211e827 fix: dev server blowing up 2026-08-20 19:40:36 +02:00
Prospector 49628a0ebc Revert "fix dev server"
This reverts commit 5b3177d27a.
2026-08-20 10:37:55 -07:00
Prospector 5b3177d27a fix dev server 2026-08-20 10:37:45 -07:00
Prospector a6043fc51f add disclosures to v2 search + docs (#7196) 2026-08-20 10:24:27 -07:00
Samalando f08d977aff fix: stretched server panel img on hosting page (#7214)
fix: stretched hosting page
2026-08-20 17:13:33 +00:00
Michael H. bee0c14b31 frontend deployment changes & stop prerendering (#7227)
* feat: frontend docker image

* build: tweak gc, disable prerendering

* feat: pass along UA

* fix: fallback to process.env

* build: remove this pr branch test

* fix: don't pin docker image ver
2026-08-20 17:07:07 +00:00
Sychic f39a602be3 docs(labrinth): misc fixes (#7228)
* docs(labrinth): fix project id param for dependencies route

* docs(labrinth): add missing params
2026-08-20 15:58:05 +00:00
Calum H.andtdgao 3e07267e10 feat: preferences syncing frontend (#7192)
* feat: prefs frontend

* fix: DI issue

* fix: lint

* feat: appearance settings cleanup + fix settings, remove pinia

* fix: sync btn when logged out

* fix: prepr

* fix: sidebar issue

* feat: language coverage + cleanup

* feat: cleanup lang settings

* fix: fmt

* fix: CI

* fix: ci

* fix: storybook

* feat: bring back loader/game version sort

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-08-20 07:23:01 +00:00
François-Xavier Talbot 79fcf41316 feat(frontend): lookup server by subdomain (#7210)
feat(frontend): lookup server by subdomain admin
2026-08-19 17:49:27 -04:00
Calum H. 10b1d7002a fix: crowdin ci (#7212)
fix: ci
2026-08-19 14:01:18 +02:00
aecsocket 8c2611805b Merge branch 'main' into boris/dev-1205-trace-rules 2026-08-19 15:56:58 +09:00
aecsocket c204e00ebc add sibling traces 2026-08-19 15:56:47 +09:00
Prospector 906ead762d update ads.txt 2026-08-18 08:52:31 -07:00
Prospector e568efa2a1 fix blog image 2026-08-17 22:31:49 -07:00
Prospector c8816418ea changelog 2026-08-17 20:40:22 -07:00
Prospector 12540a5fd7 update blog post 2026-08-17 20:40:13 -07:00
Space Walker fd7794c807 refactor: improve loader detection for Fabric derivatives (#7171)
improve loader detection for Fabric derivatives
2026-08-18 01:48:12 +00:00
aecsocket 5cf9d423ad fix up wrap err calls 2026-08-13 18:27:30 +00:00
aecsocket ac00e2bd5d allow returning strings from CEL as shorthand 2026-08-13 18:12:46 +00:00
aecsocket 43818e4f90 add dummy fixture 2026-08-13 18:03:56 +00:00
aecsocket 7ef2492d03 prepare 2026-08-13 17:50:07 +00:00
aecsocket 37d371e46c Merge branch 'main' into boris/dev-1205-trace-rules 2026-08-13 17:14:18 +00:00
aecsocket 26e1d645b8 live/outdated label on rules 2026-08-12 09:29:23 +00:00
aecsocket 04d00c0ba0 improve errors for CEL input 2026-08-12 08:12:44 +00:00
aecsocket 7f59feeb98 Merge branch 'main' of github.com:modrinth/code into boris/dev-1205-trace-rules
# Conflicts:
#	apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue
#	apps/frontend/src/pages/moderation.vue
#	apps/labrinth/.sqlx/query-06384971752b20c04d7a747777bf03a765ab60c2b029a47fcd3fe97c02ee5b2a.json
#	apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json
#	apps/labrinth/.sqlx/query-33d4e31565ece4a99832cbfd0a39bc49666e9fc8a400a5c41660504205e7cac5.json
#	apps/labrinth/.sqlx/query-4680c4a59c6679f90e3b9e1a33ed1cb1fb60b93ffb79ba5b99e01ee0c14c991a.json
#	apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json
#	apps/labrinth/.sqlx/query-87d30e8802ebe69858141d0d918cafb5286f984cfe55ad1a7ac2219ec50539a4.json
#	apps/labrinth/.sqlx/query-aedd5d9fe052a9fa70e585f389c3b4915a88cb9c3ec54493a2a29582c80b5c76.json
#	apps/labrinth/.sqlx/query-befd8c75e7ed4621f5e99d88719c0fde9d617b6603b99b75cc18d1a02387bac4.json
#	apps/labrinth/.sqlx/query-c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b.json
#	apps/labrinth/.sqlx/query-c40c2085202bc4486568fb24db9201239cefbe174f4a5cc4b8eb21a10268cd73.json
#	apps/labrinth/.sqlx/query-d37041da5452ecb82da506a83154cd66b78ebb9a1dbf0bb46e5bd6bb2b3a9bb9.json
#	apps/labrinth/.sqlx/query-d3d320af2786bf7908e65f5b7a35ade08110fb341d694ca0f25eecca2f5ad5bf.json
#	apps/labrinth/src/routes/internal/delphi/tech_review_sync.rs
#	apps/labrinth/src/routes/internal/moderation/tech_review/global.rs
2026-08-11 06:35:49 +00:00
aecsocket 4c8abfb78d adjust schema 2026-08-02 17:05:41 +01:00
aecsocket 5ead1499cd flatten input context 2026-08-02 15:13:13 +01:00
aecsocket ddb45f6aac move hidden to severity 2026-08-02 15:08:27 +01:00
aecsocket cc77fd1b87 Merge branch 'main' into boris/dev-1205-trace-rules 2026-07-30 05:15:25 +01:00
aecsocket b92fc10e54 cleanup 2026-07-27 17:47:17 +01:00
aecsocket 849943ed4e clean up db stuff 2026-07-27 10:41:26 +01:00
aecsocket 18c95e2f61 prepr 2026-07-27 10:19:54 +01:00
aecsocket 7c7efd5efe Merge branch 'main' into boris/dev-1205-trace-rules 2026-07-27 10:05:50 +01:00
aecsocket 4c5e1074fd fix 2026-07-26 15:36:41 +01:00
aecsocket 36d3aab0de fmt 2026-07-26 15:26:02 +01:00
aecsocket 88c0915066 Merge branch 'main' into boris/dev-1205-trace-rules 2026-07-26 15:20:26 +01:00
aecsocket f0dff8ebd5 clean up __dummy stuff 2026-07-26 15:17:50 +01:00
aecsocket 8d82c228cf rules affect tech review detail statuses 2026-07-24 13:49:20 +01:00
aecsocket 4b07ed720e remove old ui 2026-07-23 09:12:37 +01:00
aecsocket ea42c644f3 show in/out schema 2026-07-22 20:14:57 +01:00
aecsocket 28154689f4 run rule scans 2026-07-22 19:34:18 +01:00
aecsocket 3c77cc1502 use Ace editor 2026-07-22 18:21:33 +01:00
aecsocket b6b19e543c add demo delphi rules ui 2026-07-22 17:43:03 +01:00
aecsocket 023c789a31 wip: delphi trace transform rules 2026-07-19 09:46:12 +01:00
395 changed files with 14242 additions and 6750 deletions
+1 -2
View File
@@ -125,8 +125,7 @@ jobs:
run: pnpm install
- name: Build frontend
working-directory: ./apps/frontend
run: pnpm build
run: pnpm web:build
env:
CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
+99
View File
@@ -0,0 +1,99 @@
name: Frontend Docker
on:
push:
branches:
- main
- prod
paths:
- 'apps/frontend/**/*'
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- 'packages/api-client/**/*'
- 'packages/blog/**/*'
- '**/pnpm-*.yaml'
- .github/workflows/frontend-docker.yml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
jobs:
build:
name: Build frontend
runs-on: namespace-profile-modrinth-frontend
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Configure environment
id: meta
env:
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/main" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
else
echo "env=production" >> $GITHUB_OUTPUT
fi
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
pnpm
- name: Inject build variables
working-directory: ./apps/frontend
run: |
if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then
echo "Injecting staging variables from wrangler.jsonc..."
jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
else
echo "Injecting production variables from wrangler.jsonc..."
jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
fi
- name: Install dependencies
working-directory: ./apps/frontend
run: pnpm install
- name: Build frontend
run: pnpm web:build
env:
NITRO_PRESET: node-server
BUILD_ENV: ${{ steps.meta.outputs.env }}
CF_PAGES_BRANCH: ${{ github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
- name: Stage Docker context
run: |
mkdir -p apps/frontend/docker-stage
cp -r apps/frontend/.output apps/frontend/docker-stage/.output
- name: Upload Docker context
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
with:
name: frontend-docker-context
retention-days: 1
path: apps/frontend/docker-stage
docker-build:
needs: [build]
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
with:
image-name: frontend
dockerfile-path: apps/frontend/Dockerfile
artifacts-name: frontend-docker-context
+1 -2
View File
@@ -70,10 +70,9 @@ jobs:
run: pnpm install
- name: Build Storybook
working-directory: ./packages/ui
env:
NODE_OPTIONS: --max-old-space-size=6144
run: pnpm run build-storybook
run: pnpm build-storybook
- name: Configure short SHA
id: meta
+4 -15
View File
@@ -8,6 +8,10 @@ on:
concurrency:
group: i18n-management
permissions:
contents: write
pull-requests: write
jobs:
pull_translations:
name: 'Pull translations from Crowdin'
@@ -38,23 +42,17 @@ jobs:
flight_failure "CROWDIN_PERSONAL_TOKEN secret is not defined (required to push)"
fi
if [ "$CROWDIN_GH_TOKEN_DEFINED" != true ]; then
flight_failure "CROWDIN_GH_TOKEN secret is not defined (required to make pull requests)"
fi
if [ "$PREFLIGHT_CHECK_RESULT" = false ]; then
exit 1
fi
env:
CROWDIN_PROJECT_ID_DEFINED: ${{ vars.CROWDIN_PROJECT_ID != '' }}
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.ref }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -67,12 +65,6 @@ jobs:
- name: Install script dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Configure Git author
id: git-author
uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
env:
GITHUB_TOKEN: ${{ secrets.CROWDIN_GH_TOKEN }}
# # Because --all flag of Crowdin CLI is currently broken we need to create a fake source file
# # so that the CLI won't omit translations for it. See https://github.com/crowdin/crowdin-cli/issues/724
# - name: Write fake sources
@@ -116,7 +108,4 @@ jobs:
body-path: .github/templates/crowdin-pr.md
commit-message: 'New translations from Crowdin (${{ steps.branch-name.outputs.branch_name }})'
branch: crowdin-pull/${{ steps.branch-name.outputs.branch_name }}
author: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
committer: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
labels: sync
token: ${{ secrets.CROWDIN_GH_TOKEN }}
+1
View File
@@ -55,6 +55,7 @@ Thumbs.db
# frontend generated files
apps/frontend/src/generated
/packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.ts
.turbo
target
Generated
+81 -16
View File
@@ -437,6 +437,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "antlr4rust"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6"
dependencies = [
"better_any",
"bit-set 0.8.0",
"byteorder",
"lazy_static",
"murmur3",
"once_cell",
"parking_lot",
"typed-arena",
"uuid 1.23.3",
]
[[package]]
name = "anyhow"
version = "1.0.100"
@@ -1421,10 +1438,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c"
dependencies = [
"base64 0.21.7",
"pastey",
"pastey 0.1.1",
"serde",
]
[[package]]
name = "better_any"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4"
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -1451,7 +1474,16 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
"bit-vec",
"bit-vec 0.6.3",
]
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
@@ -1460,6 +1492,12 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -1889,6 +1927,23 @@ dependencies = [
"shlex",
]
[[package]]
name = "cel"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ed39583e427bf41d93c28c7f27c943a93bcb8697220ff3575fd53a9e13f3814"
dependencies = [
"antlr4rust",
"base64 0.22.1",
"lazy_static",
"nom 7.1.3",
"pastey 0.2.3",
"regex",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "censor"
version = "0.3.0"
@@ -2676,10 +2731,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",
@@ -3530,7 +3585,7 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2"
dependencies = [
"bit-set",
"bit-set 0.5.3",
"regex-automata",
"regex-syntax",
]
@@ -5483,6 +5538,7 @@ dependencies = [
"base64 0.22.1",
"bitflags 2.9.4",
"bytes",
"cel",
"censor",
"chrono",
"chumsky",
@@ -6170,6 +6226,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb585ade2549a017db2e35978b77c319214fa4b37cede841e27954dd6e8f3ca8"
[[package]]
name = "murmur3"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c"
dependencies = [
"byteorder",
]
[[package]]
name = "mutually_exclusive_features"
version = "0.1.0"
@@ -7192,6 +7257,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "path-util"
version = "0.0.0"
@@ -9433,18 +9504,6 @@ dependencies = [
"typeid",
]
[[package]]
name = "serde-xml-rs"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53630160a98edebde0123eb4dfd0fce6adff091b2305db3154a9e920206eb510"
dependencies = [
"log",
"serde",
"thiserror 1.0.69",
"xml-rs",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
@@ -11875,6 +11934,12 @@ dependencies = [
"utf-8",
]
[[package]]
name = "typed-arena"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
[[package]]
name = "typed-path"
version = "0.12.0"
+1 -1
View File
@@ -57,6 +57,7 @@ bitflags = "2.9.4"
bon = "3.9.3"
bytemuck = "1.24.0"
bytes = "1.10.1"
cel = { version = "0.14.0", default-features = false, features = ["json", "regex"] }
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.42"
@@ -192,7 +193,6 @@ serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.145"
serde_with = "3.15.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
-1
View File
@@ -38,7 +38,6 @@
"intl-messageformat": "^10.7.7",
"ofetch": "^1.3.4",
"overlayscrollbars": "^2.15.1",
"pinia": "^3.0.0",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
+101 -23
View File
@@ -94,6 +94,9 @@ import SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { useError } from '@/composables/use-error.js'
import { useTheme } from '@/composables/use-theme.ts'
import { config } from '@/config'
import {
hide_ads_window,
@@ -147,8 +150,7 @@ import { setupProviders } from '@/providers/setup'
import { setupAppEventsProvider } from '@/providers/setup/app-events'
import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { useError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import { setupAppUserPreferencesProvider } from '@/providers/setup/user-preferences.ts'
import { appMessages } from '@/utils/app-messages'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
@@ -157,7 +159,8 @@ import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming()
const appSettings = useAppSettings()
const appTheme = useTheme()
const router = useRouter()
const route = useRoute()
const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider()
@@ -201,7 +204,7 @@ const credentials = ref()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
watch(
() => themeStore.toggleSidebar,
() => appSettings.toggleSidebar,
(toggleSidebar) => {
sidebarToggled.value = !toggleSidebar
},
@@ -221,7 +224,7 @@ const hostingUpdateRequired = computed(
appUpdateState.updatesEnabled.value,
)
const prideFundraiserEnabled = computed(
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
() => appSettings.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
)
const hostingIntercomIdentityKey = computed(() => {
const rawServerId = route.params.id
@@ -324,13 +327,13 @@ providePageContext({
intercomBubble: hostingIntercom.intercomBubble,
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
appSettings.getFeatureFlag('server_ram_as_bytes_always_on'),
),
},
openExternalUrl: (url) => void openUrl(url),
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
noblur: computed(() => !appTheme.advancedRendering),
onShow: () => take_ads_window_hold(),
onHide: () => release_ads_window_hold(),
})
@@ -634,10 +637,11 @@ async function setupApp() {
theme,
locale,
telemetry,
collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
toggle_sidebar,
sync_theme_across_devices,
sync_behavior_across_devices,
developer_mode,
feature_flags,
pending_update_toast_for_version,
@@ -655,13 +659,14 @@ async function setupApp() {
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
themeStore.setThemeState(theme)
themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering
themeStore.hideNametagSkinsPage = hide_nametag_skins_page
themeStore.toggleSidebar = toggle_sidebar
themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags
appTheme.preferred = theme
appTheme.advancedRendering = advanced_rendering
appTheme.syncAcrossDevices = sync_theme_across_devices
appSettings.syncBehaviorAcrossDevices = sync_behavior_across_devices
appSettings.hideNametagSkinsPage = hide_nametag_skins_page
appSettings.toggleSidebar = toggle_sidebar
appSettings.devMode = developer_mode
Object.assign(appSettings.featureFlags, feature_flags)
stateInitialized.value = true
isMaximized.value = await getCurrentWindow().isMaximized()
@@ -959,7 +964,7 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
const authProvider = setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
if (options?.showModal === false) {
await signIn(flow)
} else {
@@ -967,6 +972,80 @@ setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
}
})
const userPreferences = setupAppUserPreferencesProvider(authProvider, notificationManager)
let userPreferencesSync = Promise.resolve()
watch(
[userPreferences.preferences, stateInitialized],
([preferences, initialized]) => {
if (!preferences || !initialized) return
userPreferencesSync = userPreferencesSync
.then(async () => {
const settings = await getSettings()
const selectedTheme = preferences.appearance.auto ? 'system' : preferences.appearance.theme
const locale = preferences.localization.locale
const behavior = preferences.behavior
let settingsChanged = false
if (appTheme.syncAcrossDevices && appTheme.preferred !== selectedTheme) {
appTheme.preferred = selectedTheme
}
if (i18n.global.locale.value !== locale) {
i18n.global.locale.value = locale
}
if (appTheme.syncAcrossDevices && settings.theme !== selectedTheme) {
settings.theme = selectedTheme
settingsChanged = true
}
if (settings.locale !== locale) {
settings.locale = locale
settingsChanged = true
}
if (behavior && appSettings.syncBehaviorAcrossDevices) {
const behaviorFeatureFlags = {
worlds_in_home: behavior.show_jump_in,
compact_instance_cards: behavior.compact_instance_cards,
show_instance_play_time: behavior.show_play_time,
skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks,
skip_non_essential_warnings: behavior.skip_non_essential_warnings,
}
appSettings.toggleSidebar = behavior.hide_right_sidebar
appSettings.hideNametagSkinsPage = behavior.hide_nametag
Object.assign(appSettings.featureFlags, behaviorFeatureFlags)
if (settings.hide_on_process_start !== behavior.minimize_app) {
settings.hide_on_process_start = behavior.minimize_app
settingsChanged = true
}
if (settings.toggle_sidebar !== behavior.hide_right_sidebar) {
settings.toggle_sidebar = behavior.hide_right_sidebar
settingsChanged = true
}
if (settings.hide_nametag_skins_page !== behavior.hide_nametag) {
settings.hide_nametag_skins_page = behavior.hide_nametag
settingsChanged = true
}
for (const [flag, value] of Object.entries(behaviorFeatureFlags)) {
if (settings.feature_flags[flag] !== value) {
settings.feature_flags[flag] = value
settingsChanged = true
}
}
}
if (settingsChanged) {
await setSettings(settings)
}
})
.catch(handleError)
},
{ immediate: true },
)
async function validateSession(sessionToken) {
try {
const response = await tauriFetch(`${config.labrinthBaseUrl}/v2/user`, {
@@ -1523,9 +1602,8 @@ async function downloadUpdate(versionToDownload) {
.then(() => {
downloading.value = false
finishedDownloading.value = true
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
unlistenUpdateDownload?.()
unlistenUpdateDownload = null
console.log('Finished downloading!')
markAppUpdateActionable(versionToDownload.version, 'downloaded')
scheduleDelayedUpdatePopup()
@@ -1642,7 +1720,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div
v-if="stateInitialized"
class="app-grid-layout relative"
:class="{ 'disable-advanced-rendering': !themeStore.advancedRendering }"
:class="{ 'disable-advanced-rendering': !appTheme.advancedRendering }"
>
<Transition name="fade">
<div
@@ -1828,7 +1906,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<IconButton
v-if="!forceSidebar && themeStore.toggleSidebar"
v-if="!forceSidebar && appSettings.toggleSidebar"
:type="sidebarToggled ? 'base' : 'quiet'"
:label="formatMessage(messages.nextImage)"
class="mr-3 transition-transform"
@@ -1851,7 +1929,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="app-contents"
:class="{
'sidebar-enabled': sidebarVisible,
'disable-advanced-rendering': !themeStore.advancedRendering,
'disable-advanced-rendering': !appTheme.advancedRendering,
}"
>
<div class="app-viewport flex-grow router-view">
@@ -1867,7 +1945,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<LoadingBar position="absolute" />
</div>
<div
v-if="themeStore.featureFlags.page_path"
v-if="appSettings.featureFlags.page_path"
class="absolute bottom-0 left-0 m-2 bg-tooltip-bg text-tooltip-text font-semibold rounded-full px-2 py-1 text-xs z-50"
>
{{ route.fullPath }}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

@@ -20,6 +20,7 @@
<div class="flex gap-2 w-full min-w-0">
<Avatar
size="36px"
disable-conditional-icon-padding
:src="
selectedAccount
? avatarUrl
@@ -46,7 +47,11 @@
class="w-5 h-5 text-brand shrink-0"
/>
<RadioButtonIcon v-else class="w-5 h-5 text-secondary shrink-0" />
<Avatar :src="getAccountAvatarUrl(account)" size="24px" />
<Avatar
:src="getAccountAvatarUrl(account)"
size="24px"
disable-conditional-icon-padding
/>
<p
class="m-0 truncate min-w-0"
:class="
@@ -107,6 +112,7 @@ import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import {
get_default_user,
@@ -118,7 +124,6 @@ import {
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
@@ -1,203 +0,0 @@
<template>
<transition name="fade">
<div
v-show="shown"
ref="contextMenu"
class="context-menu"
:style="{
left: left,
top: top,
}"
>
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
<hr v-if="option.type === 'divider'" class="divider" />
<div
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
class="item clickable"
:class="[option.color ?? 'base']"
>
<slot :name="option.name" />
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['menu-closed', 'option-clicked'])
const item = ref(null)
const contextMenu = ref(null)
const options = ref([])
const left = ref('0px')
const top = ref('0px')
const shown = ref(false)
const contextMenuId = Symbol()
const contextMenuOpenEvent = 'modrinth-context-menu-open'
const hideContextMenu = () => {
shown.value = false
emit('menu-closed')
}
defineExpose({
showMenu: (event, passedItem, passedOptions) => {
window.dispatchEvent(new CustomEvent(contextMenuOpenEvent, { detail: contextMenuId }))
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'
}
})
},
hideMenu: hideContextMenu,
})
const isInstanceLink = (item) => {
if (item.instance != undefined && item.instance.link) {
return true
} else if (item != undefined && item.link) {
return true
}
return false
}
const optionClicked = (option) => {
emit('option-clicked', {
item: item.value,
option: option,
})
hideContextMenu()
}
const onEscKeyRelease = (event) => {
if (event.keyCode === 27) {
hideContextMenu()
}
}
const handleContextMenuOpen = (event) => {
if (shown.value && event.detail !== contextMenuId) {
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)
window.addEventListener(contextMenuOpenEvent, handleContextMenuOpen)
document.body.addEventListener('keyup', onEscKeyRelease)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
window.removeEventListener(contextMenuOpenEvent, handleContextMenuOpen)
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-text-primary);
font-weight: 500;
cursor: pointer;
display: flex;
gap: var(--gap-sm);
padding: var(--gap-sm);
border-radius: var(--radius-sm);
:deep(svg) {
color: var(--color-base);
}
&:hover,
&:active {
:deep(svg) {
color: inherit;
}
&.base {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
&.primary {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: 500;
}
&.danger {
background-color: var(--color-red);
color: var(--color-accent-contrast);
font-weight: 500;
}
&.contrast {
background-color: var(--color-orange);
color: var(--color-accent-contrast);
font-weight: 500;
}
}
}
.divider {
border: 1px solid var(--color-divider);
margin: var(--gap-sm);
pointer-events: none;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -20,11 +20,11 @@ import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install_existing_instance } from '@/helpers/install'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
@@ -20,12 +20,12 @@ 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 { 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()
@@ -11,7 +11,7 @@ import { getInstanceIconUrl, list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
const APPROX_USED_VERTICAL_SPACE = 475 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
const STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
@@ -39,17 +39,17 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const themeStore = useTheming()
const appSettings = useAppSettings()
const nativeDecorations = ref(true)
const isMaximized = ref(false)
const os = ref('')
const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
const alwaysShowAppControls = computed(() => appSettings.getFeatureFlag('always_show_app_controls'))
const showControls = computed(
() =>
@@ -0,0 +1,236 @@
<template>
<Teleport to="#teleports">
<transition name="fade">
<div
v-show="shown && !isMobileActiveSubmenu"
ref="contextMenu"
class="context-menu"
:style="menuStyle"
role="menu"
@keydown="handleMenuKeydown"
@mousemove="(event) => handleMenuMouseMove(event, 'menu')"
>
<template v-for="(option, index) in options" :key="index">
<hr v-if="isDivider(option)" class="divider" />
<button
v-else-if="isOptionVisible(option)"
:ref="(element) => setOptionButtonRef(index, element)"
type="button"
class="item clickable"
:class="[
option.color ?? 'base',
{
active: index === activeOptionIndex,
'safe-triangle-hover': index === pendingOptionIndex,
},
]"
role="menuitem"
:aria-haspopup="hasChildren(option) ? 'menu' : undefined"
:aria-expanded="hasChildren(option) ? index === activeOptionIndex : undefined"
@click.stop="handleOptionClick(option, index)"
@focus="handleOptionFocus(option, index)"
@mouseenter="handleOptionMouseEnter(option, index)"
>
<span class="item-content"><slot :name="option.name" /></span>
<ChevronRightIcon v-if="hasChildren(option)" class="submenu-chevron" />
</button>
</template>
</div>
</transition>
</Teleport>
<Teleport to="#teleports">
<transition name="fade">
<div
v-if="shown && activeOption && hasSubmenuPosition"
ref="submenu"
class="context-menu submenu"
:style="submenuStyle"
role="menu"
@keydown="handleSubmenuKeydown"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="isCursorInsideSubmenu = false"
@mousemove="(event) => handleMenuMouseMove(event, 'submenu')"
>
<button
v-if="isMobileSubmenuLayout"
type="button"
class="item clickable base mobile-back"
@click.stop="returnToMenu"
>
<ChevronLeftIcon class="submenu-chevron" />
<span class="item-content"><slot :name="activeOption.name" /></span>
</button>
<template v-for="(option, index) in activeOption.children" :key="index">
<hr v-if="isDivider(option)" class="divider" />
<button
v-else-if="isOptionVisible(option)"
:ref="(element) => setSubmenuButtonRef(index, element)"
type="button"
class="item clickable"
:class="option.color ?? 'base'"
role="menuitem"
@click.stop="optionClicked(option.name)"
>
<span class="item-content"><slot :name="option.name" /></span>
</button>
</template>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import type { ContextMenuEmit } from './types'
import { useContextMenu } from './use-context-menu'
import { hasChildren, isDivider } from './utils'
const emit = defineEmits<ContextMenuEmit>()
const {
shown,
contextMenu,
submenu,
options,
menuStyle,
submenuStyle,
activeOption,
activeOptionIndex,
pendingOptionIndex,
hasSubmenuPosition,
isMobileSubmenuLayout,
isMobileActiveSubmenu,
isCursorInsideSubmenu,
isOptionVisible,
setOptionButtonRef,
setSubmenuButtonRef,
showMenu,
hideMenu,
handleOptionClick,
optionClicked,
handleOptionFocus,
handleOptionMouseEnter,
returnToMenu,
handleSubmenuMouseEnter,
handleMenuMouseMove,
handleMenuKeydown,
handleSubmenuKeydown,
} = useContextMenu(emit)
defineExpose({ showMenu, hideMenu })
</script>
<style lang="scss" scoped>
.context-menu {
background-color: var(--color-raised-bg);
border-radius: var(--radius-md);
box-shadow: var(--shadow-floating);
border: 1px solid var(--color-divider);
margin: 0;
position: fixed;
z-index: 1000000;
overflow: hidden;
padding: var(--gap-sm);
min-width: 12rem;
max-width: calc(100vw - 1.25rem);
&.submenu {
z-index: 1000001;
}
.item {
align-items: center;
background: transparent;
border: 0;
box-shadow: none;
color: var(--color-text-primary);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: inherit;
font-weight: 500;
gap: var(--gap-sm);
justify-content: space-between;
padding: var(--gap-sm);
border-radius: var(--radius-sm);
text-align: left;
width: 100%;
.item-content {
align-items: center;
display: flex;
gap: var(--gap-sm);
min-width: 0;
}
.submenu-chevron {
color: var(--color-text-secondary);
flex-shrink: 0;
height: 1.25rem;
width: 1.25rem;
margin-right: -0.25rem;
}
:deep(svg) {
color: var(--color-base);
}
&:hover:not(.safe-triangle-hover),
&:active,
&:focus-visible,
&.active {
:deep(svg) {
color: inherit;
}
&.base {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
&.primary {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: 500;
}
&.danger {
background-color: var(--color-red);
color: var(--color-accent-contrast);
font-weight: 500;
}
&.contrast {
background-color: var(--color-orange);
color: var(--color-accent-contrast);
font-weight: 500;
}
}
}
.mobile-back {
border-bottom: 1px solid var(--color-divider);
border-radius: 0;
margin: calc(var(--gap-sm) * -1) calc(var(--gap-sm) * -1) var(--gap-sm);
width: calc(100% + var(--gap-sm) * 2);
}
.divider {
border: 1px solid var(--color-divider);
margin: var(--gap-sm);
pointer-events: none;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,41 @@
import type { ComponentPublicInstance } from 'vue'
export type ContextMenuDivider = {
type: string
}
export type ContextMenuAction = {
name: string
color?: string
children?: ContextMenuOption[]
}
export type ContextMenuParentAction = ContextMenuAction & {
children: ContextMenuOption[]
}
export type ContextMenuOption = ContextMenuDivider | ContextMenuAction
export type ContextMenuSelection = {
item: unknown
option: string
}
export type ContextMenuEmit = {
(event: 'menu-closed'): void
(event: 'option-clicked', selection: ContextMenuSelection): void
}
export type Point = {
x: number
y: number
}
export type ViewportRect = {
width: number
height: number
offsetTop: number
offsetLeft: number
}
export type ButtonRefElement = Element | ComponentPublicInstance | null
@@ -0,0 +1,170 @@
import type { CSSProperties, Ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import type { ContextMenuParentAction, Point, ViewportRect } from './types'
const MENU_GAP = 8
const VIEWPORT_MARGIN = 10
type ContextMenuPositionOptions = {
shown: Ref<boolean>
activeOption: Ref<ContextMenuParentAction | null>
activeOptionIndex: Ref<number | null>
isMobileSubmenuLayout: Ref<boolean>
contextMenu: Ref<HTMLElement | null>
submenu: Ref<HTMLElement | null>
optionButtonRefs: Map<number, HTMLElement>
}
export function useContextMenuPosition({
shown,
activeOption,
activeOptionIndex,
isMobileSubmenuLayout,
contextMenu,
submenu,
optionButtonRefs,
}: ContextMenuPositionOptions) {
const menuStyle = ref<CSSProperties>({ left: '0px', top: '0px' })
const menuAnchor = ref<Point>({ x: 0, y: 0 })
const submenuPosition = ref<Point>({ x: 0, y: 0 })
const hasSubmenuPosition = ref(false)
let positionRafId: number | null = null
const submenuStyle = computed<CSSProperties>(() => {
if (isMobileSubmenuLayout.value) return menuStyle.value
return {
left: `${submenuPosition.value.x}px`,
top: `${submenuPosition.value.y}px`,
}
})
function updateMenuPosition(x: number, y: number) {
if (!contextMenu.value) return
const viewport = getViewportRect()
const menuRect = contextMenu.value.getBoundingClientRect()
const viewportLeft = viewport.offsetLeft
const viewportTop = viewport.offsetTop
const viewportRight = viewport.offsetLeft + viewport.width
const viewportBottom = viewport.offsetTop + viewport.height
const anchorX = x + viewport.offsetLeft
const anchorY = y + viewport.offsetTop
const left = Math.min(
Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP),
Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN),
)
const top = Math.min(
Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP),
Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN),
)
menuStyle.value = { left: `${left}px`, top: `${top}px` }
if (activeOption.value) scheduleSubmenuPositionUpdate()
}
function updateSubmenuPosition() {
if (!activeOption.value || activeOptionIndex.value === null) return false
if (isMobileSubmenuLayout.value) {
hasSubmenuPosition.value = true
return true
}
const optionButton = optionButtonRefs.get(activeOptionIndex.value)
if (!optionButton || !contextMenu.value) return false
const viewport = getViewportRect()
const buttonRect = optionButton.getBoundingClientRect()
const menuRect = contextMenu.value.getBoundingClientRect()
const submenuRect = submenu.value?.getBoundingClientRect()
const submenuWidth = submenuRect?.width ?? menuRect.width
const submenuHeight = submenuRect?.height ?? 100
const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport)
const preferredLeft =
direction === 'right'
? buttonRect.right + MENU_GAP
: buttonRect.left - submenuWidth - MENU_GAP
const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN
const maxLeft = Math.max(
minLeft,
viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN,
)
const minTop = viewport.offsetTop + VIEWPORT_MARGIN
const maxTop = Math.max(
minTop,
viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN,
)
submenuPosition.value = {
x: Math.min(Math.max(minLeft, preferredLeft), maxLeft),
y: Math.min(Math.max(minTop, buttonRect.top), maxTop),
}
hasSubmenuPosition.value = true
return true
}
function scheduleSubmenuPositionUpdate(retries = 8) {
nextTick(() => {
if (!shown.value || !activeOption.value) return
const hasRenderedSubmenu = submenu.value !== null
if (updateSubmenuPosition()) {
if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition)
return
}
if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0)
})
}
function schedulePositionUpdate() {
if (!shown.value || positionRafId !== null) return
positionRafId = window.requestAnimationFrame(() => {
positionRafId = null
updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y)
})
}
function cancelPositionUpdate() {
if (positionRafId !== null) window.cancelAnimationFrame(positionRafId)
}
return {
menuStyle,
menuAnchor,
submenuStyle,
hasSubmenuPosition,
updateMenuPosition,
scheduleSubmenuPositionUpdate,
schedulePositionUpdate,
cancelPositionUpdate,
}
}
function getViewportRect(): ViewportRect {
const visualViewport = window.visualViewport
return {
width: visualViewport?.width ?? window.innerWidth,
height: visualViewport?.height ?? window.innerHeight,
offsetTop: visualViewport?.offsetTop ?? 0,
offsetLeft: visualViewport?.offsetLeft ?? 0,
}
}
function getSubmenuOpenDirection(
menuRect: DOMRect,
submenuWidth: number,
viewport: ViewportRect,
): 'left' | 'right' {
const viewportLeft = viewport.offsetLeft
const viewportRight = viewport.offsetLeft + viewport.width
const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN
const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN
if (rightSpace >= submenuWidth) return 'right'
if (leftSpace >= submenuWidth) return 'left'
return rightSpace >= leftSpace ? 'right' : 'left'
}
@@ -0,0 +1,357 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type {
ButtonRefElement,
ContextMenuAction,
ContextMenuEmit,
ContextMenuOption,
ContextMenuParentAction,
Point,
} from './types'
import { useContextMenuPosition } from './use-context-menu-position'
import {
focusRelativeButton,
getFocusableButtons,
hasChildren,
isAction,
isInstanceLink,
isPointInTriangle,
} from './utils'
const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open'
export function useContextMenu(emit: ContextMenuEmit) {
const item = ref<unknown>(null)
const contextMenu = ref<HTMLElement | null>(null)
const submenu = ref<HTMLElement | null>(null)
const options = ref<ContextMenuOption[]>([])
const shown = ref(false)
const activeOptionIndex = ref<number | null>(null)
const pendingOptionIndex = ref<number | null>(null)
const isCursorInsideSubmenu = ref(false)
const isMobileSubmenuLayout = ref(false)
const lastMousePosition = ref<Point | null>(null)
const contextMenuId = Symbol()
const optionButtonRefs = new Map<number, HTMLElement>()
const submenuButtonRefs = new Map<number, HTMLElement>()
let previousMousePosition: Point | null = null
let pendingOptionTimeout: ReturnType<typeof setTimeout> | null = null
let mobileSubmenuMediaQuery: MediaQueryList | null = null
const activeOption = computed<ContextMenuParentAction | null>(() => {
if (activeOptionIndex.value === null) return null
const option = options.value[activeOptionIndex.value]
return option && isAction(option) && hasChildren(option) ? option : null
})
const {
menuStyle,
menuAnchor,
submenuStyle,
hasSubmenuPosition,
updateMenuPosition,
scheduleSubmenuPositionUpdate,
schedulePositionUpdate,
cancelPositionUpdate,
} = useContextMenuPosition({
shown,
activeOption,
activeOptionIndex,
isMobileSubmenuLayout,
contextMenu,
submenu,
optionButtonRefs,
})
const isMobileActiveSubmenu = computed(
() => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value,
)
function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction {
return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content')
}
function setOptionButtonRef(index: number, element: ButtonRefElement) {
setButtonRef(optionButtonRefs, index, element)
}
function setSubmenuButtonRef(index: number, element: ButtonRefElement) {
setButtonRef(submenuButtonRefs, index, element)
}
function hideMenu() {
if (!shown.value) return
shown.value = false
deactivateSubmenu()
emit('menu-closed')
}
function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) {
window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId }))
item.value = passedItem
options.value = passedOptions
menuAnchor.value = { x: event.clientX, y: event.clientY }
shown.value = true
deactivateSubmenu()
syncMobileSubmenuLayout()
nextTick(() => updateMenuPosition(event.clientX, event.clientY))
}
function handleOptionClick(option: ContextMenuAction, index: number) {
if (hasChildren(option)) {
activateSubmenu(index)
return
}
optionClicked(option.name)
}
function optionClicked(option: string) {
emit('option-clicked', { item: item.value, option })
hideMenu()
}
function handleOptionFocus(option: ContextMenuAction, index: number) {
if (hasChildren(option) && !isMobileSubmenuLayout.value) {
activateSubmenu(index)
} else if (!hasChildren(option)) {
deactivateSubmenu()
}
}
function handleOptionMouseEnter(option: ContextMenuAction, index: number) {
if (isMobileSubmenuLayout.value) return
if (activeOptionIndex.value === null) {
if (hasChildren(option)) activateSubmenu(index)
return
}
if (activeOptionIndex.value === index) return
if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) {
commitHoveredOption(index)
return
}
pendingOptionIndex.value = index
clearPendingOptionTimeout()
pendingOptionTimeout = setTimeout(() => {
if (pendingOptionIndex.value !== index) return
if (isCursorInsideSubmenu.value) {
pendingOptionIndex.value = null
return
}
commitHoveredOption(index)
}, 180)
}
function commitHoveredOption(index: number) {
const option = options.value[index]
if (option && hasChildren(option)) {
activateSubmenu(index)
} else {
deactivateSubmenu()
}
}
function activateSubmenu(index: number) {
clearPendingOptionTimeout()
pendingOptionIndex.value = null
activeOptionIndex.value = index
hasSubmenuPosition.value = false
scheduleSubmenuPositionUpdate()
}
function deactivateSubmenu() {
clearPendingOptionTimeout()
activeOptionIndex.value = null
pendingOptionIndex.value = null
hasSubmenuPosition.value = false
isCursorInsideSubmenu.value = false
lastMousePosition.value = null
previousMousePosition = null
}
function returnToMenu() {
const previousIndex = activeOptionIndex.value
deactivateSubmenu()
nextTick(() => {
if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus()
})
}
function handleSubmenuMouseEnter() {
isCursorInsideSubmenu.value = true
clearPendingOptionTimeout()
pendingOptionIndex.value = null
}
function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') {
previousMousePosition = lastMousePosition.value
lastMousePosition.value = { x: event.clientX, y: event.clientY }
if (
source === 'menu' &&
pendingOptionIndex.value !== null &&
!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)
) {
commitHoveredOption(pendingOptionIndex.value)
}
}
function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) {
const submenuRect = submenu.value?.getBoundingClientRect()
if (!submenuRect || !cursor || !origin) return false
const submenuTargetX =
origin.x <= submenuRect.left
? submenuRect.left
: origin.x >= submenuRect.right
? submenuRect.right
: cursor.x <= submenuRect.left
? submenuRect.left
: submenuRect.right
const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 }
const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 }
return isPointInTriangle(cursor, origin, upperTarget, lowerTarget)
}
function handleMenuKeydown(event: KeyboardEvent) {
const buttons = getFocusableButtons(optionButtonRefs)
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
} else if (event.key === 'ArrowRight') {
const focusedIndex = [...optionButtonRefs.entries()].find(
([, button]) => button === document.activeElement,
)?.[0]
const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex]
if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) {
event.preventDefault()
activateSubmenu(focusedIndex)
nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus())
}
} else if (event.key === 'Escape') {
event.preventDefault()
hideMenu()
}
}
function handleSubmenuKeydown(event: KeyboardEvent) {
const buttons = getFocusableButtons(submenuButtonRefs)
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
returnToMenu()
} else if (event.key === 'Escape') {
event.preventDefault()
hideMenu()
}
}
function syncMobileSubmenuLayout(event?: MediaQueryListEvent) {
isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false
}
function handleDocumentKeydown(event: KeyboardEvent) {
if (shown.value && event.key === 'Escape') hideMenu()
}
function handleContextMenuOpen(event: Event) {
if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu()
}
function handleClickOutside(event: MouseEvent) {
const target = event.target
if (!(target instanceof Node)) return
if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu()
}
onMounted(() => {
mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY)
syncMobileSubmenuLayout()
mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout)
window.addEventListener('click', handleClickOutside)
window.addEventListener('resize', schedulePositionUpdate)
window.addEventListener('scroll', schedulePositionUpdate, true)
window.visualViewport?.addEventListener('scroll', schedulePositionUpdate)
window.visualViewport?.addEventListener('resize', schedulePositionUpdate)
window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
document.addEventListener('keydown', handleDocumentKeydown)
})
onBeforeUnmount(() => {
clearPendingOptionTimeout()
cancelPositionUpdate()
mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout)
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', schedulePositionUpdate)
window.removeEventListener('scroll', schedulePositionUpdate, true)
window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate)
window.visualViewport?.removeEventListener('resize', schedulePositionUpdate)
window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
document.removeEventListener('keydown', handleDocumentKeydown)
})
function clearPendingOptionTimeout() {
if (!pendingOptionTimeout) return
clearTimeout(pendingOptionTimeout)
pendingOptionTimeout = null
}
return {
shown,
contextMenu,
submenu,
options,
menuStyle,
submenuStyle,
activeOption,
activeOptionIndex,
pendingOptionIndex,
hasSubmenuPosition,
isMobileSubmenuLayout,
isMobileActiveSubmenu,
isCursorInsideSubmenu,
isOptionVisible,
setOptionButtonRef,
setSubmenuButtonRef,
showMenu,
hideMenu,
handleOptionClick,
optionClicked,
handleOptionFocus,
handleOptionMouseEnter,
returnToMenu,
handleSubmenuMouseEnter,
handleMenuMouseMove,
handleMenuKeydown,
handleSubmenuKeydown,
}
}
function setButtonRef(
buttonRefs: Map<number, HTMLElement>,
index: number,
element: ButtonRefElement,
) {
if (element instanceof HTMLElement) {
buttonRefs.set(index, element)
} else {
buttonRefs.delete(index)
}
}
@@ -0,0 +1,59 @@
import type {
ContextMenuAction,
ContextMenuDivider,
ContextMenuOption,
ContextMenuParentAction,
Point,
} from './types'
export function isAction(option: ContextMenuOption): option is ContextMenuAction {
return 'name' in option
}
export function isDivider(option: ContextMenuOption): option is ContextMenuDivider {
return 'type' in option && option.type === 'divider'
}
export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction {
return isAction(option) && Boolean(option.children?.length)
}
export function isInstanceLink(value: unknown) {
if (!value || typeof value !== 'object') return false
if ('instance' in value) {
const instance = value.instance
return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link)
}
return 'link' in value && Boolean(value.link)
}
export function getFocusableButtons(buttonRefs: Map<number, HTMLElement>) {
return [...buttonRefs.entries()]
.sort(([left], [right]) => left - right)
.map(([, button]) => button)
.filter((button) => button.offsetParent !== null)
}
export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) {
if (!buttons.length) return
const currentIndex = buttons.indexOf(document.activeElement as HTMLElement)
const nextIndex =
currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction
buttons[(nextIndex + buttons.length) % buttons.length]?.focus()
}
export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) {
const area = triangleArea(a, b, c)
const area1 = triangleArea(point, b, c)
const area2 = triangleArea(a, point, c)
const area3 = triangleArea(a, b, point)
return Math.abs(area - (area1 + area2 + area3)) < 0.5
}
function triangleArea(a: Point, b: Point, c: Point) {
return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2)
}
@@ -15,17 +15,17 @@ import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const themeStore = useTheming()
const appSettings = useAppSettings()
const props = defineProps<{
credentials: ModrinthCredentials | null
@@ -39,11 +39,11 @@ type FriendsSectionCollapsedFlag =
| 'friends_pending_collapsed'
function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
return themeStore.getFeatureFlag(flag)
return appSettings.getFeatureFlag(flag)
}
function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
themeStore.featureFlags[flag] = collapsed
appSettings.featureFlags[flag] = collapsed
getSettings()
.then((settings) => {
settings.feature_flags[flag] = collapsed
@@ -11,7 +11,7 @@ import {
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
@@ -13,11 +13,10 @@
import { UnknownFileWarningModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { type FeatureFlag, useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const appSettings = useAppSettings()
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const modal = useTemplateRef('modal')
@@ -34,7 +33,7 @@ function show(
fileName.value = selectedFileName
externalFilesInModpack.value = selectedExternalFiles
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
if (appSettings.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
void createInstance()
return
}
@@ -50,7 +49,7 @@ function reset() {
async function proceed(dontShowAgain: boolean) {
if (dontShowAgain) {
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
appSettings.featureFlags[skipUnknownPackWarningFeatureFlag] = true
const settings = await getSettings()
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
await setSettings(settings)
@@ -1,4 +1,4 @@
import { defineMessages } from '@modrinth/ui'
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
import backpack from '@/assets/instance-icons/backpack.png'
import beacon from '@/assets/instance-icons/beacon.png'
@@ -18,6 +18,8 @@ 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'
@@ -25,11 +27,13 @@ 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'
@@ -197,8 +201,20 @@ const names = defineMessages({
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',
@@ -339,6 +355,39 @@ export const backgroundOptions = [
] 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' },
@@ -438,7 +487,7 @@ export const symbolOptions = [
{ 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
] as const satisfies readonly SymbolOption[]
export type BackgroundId = (typeof backgroundOptions)[number]['id']
export type SymbolId = (typeof symbolOptions)[number]['id']
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { CheckIcon, InfoIcon, RefreshCwIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
commonMessages,
defineMessages,
@@ -26,6 +27,7 @@ import {
DEFAULT_SYMBOL_ID,
RANDOM_CONFIG_BLACKLIST,
type SymbolId,
type SymbolOption,
symbolOptions,
} from './editor-catalog'
@@ -201,7 +203,8 @@ function surpriseMe() {
const configurations = backgroundOptions.flatMap((background) =>
symbolOptions
.filter(
(symbol) =>
(symbol: SymbolOption) =>
!symbol.excludeFromRandomization &&
background.id !== selectedBackground.value &&
symbol.id !== selectedSymbol.value &&
!RANDOM_CONFIG_BLACKLIST.some(
@@ -342,37 +345,37 @@ const messages = defineMessages({
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 rounded-[20px] border border-solid border-surface-4 bg-surface-2 p-4"
class="flex w-full flex-col items-center gap-3.5 rounded-[20px] border border-solid border-surface-4 bg-surface-2 p-5"
>
<div
class="icon-outline relative size-[132px] overflow-hidden rounded-[20px]"
<Avatar
:src="selectedSymbolOption.asset"
size="132px"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
no-shadow
/>
<div class="flex items-center gap-2.5">
<div
class="icon-outline relative size-12 overflow-hidden rounded-2xl"
<Avatar
:src="selectedSymbolOption.asset"
size="40px"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-8 overflow-hidden rounded-[10px]"
no-shadow
/>
<Avatar
:src="selectedSymbolOption.asset"
size="30px"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-4 overflow-hidden rounded-[5px]"
no-shadow
/>
<Avatar
:src="selectedSymbolOption.asset"
size="20px"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
no-shadow
/>
</div>
</div>
<Button class="w-full !shadow-none" @click="surpriseMe">
<Button class="w-full" @click="surpriseMe">
<RefreshCwIcon />
{{ formatMessage(messages.surpriseMe) }}
</Button>
@@ -12,7 +12,10 @@
<span class="text-2xl font-semibold text-contrast">
{{
formatMessage(messages.title, {
groupName: groupInstancesModalGroup?.name ?? '',
groupName:
groupInstancesModalGroup?.id === 'group:none'
? formatMessage(messages.ungrouped)
: (groupInstancesModalGroup?.name ?? ''),
})
}}
</span>
@@ -53,6 +56,10 @@
</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)" />
@@ -100,6 +107,10 @@ 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}"',
@@ -1,20 +1,24 @@
<script setup lang="ts">
import {
ClipboardCopyIcon,
EditIcon,
EyeIcon,
FolderOpenIcon,
MinusIcon,
PaletteIcon,
PlayIcon,
PlusIcon,
StarIcon,
StopCircleIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
import Draggable from 'vuedraggable'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
@@ -55,6 +59,30 @@ const messages = defineMessages({
id: 'app.library.instance.action.view-instance',
defaultMessage: 'View instance',
},
editIcon: {
id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon',
},
selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon',
},
createIcon: {
id: 'instance.settings.tabs.general.edit-icon.create',
defaultMessage: 'Create an icon',
},
editCreatedIcon: {
id: 'instance.settings.tabs.general.edit-icon.edit-created',
defaultMessage: 'Edit icon',
},
removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon',
},
duplicateInstance: {
id: 'app.library.instance.action.duplicate',
defaultMessage: 'Duplicate instance',
@@ -78,10 +106,13 @@ const {
reorderGroups,
instanceOptions,
confirmDeleteModal,
iconEditorModal,
currentIconEditorInstance,
currentDeleteInstances,
clearLibraryInstanceSelection,
deleteInstance,
handleInstanceOption,
handleInstanceIconSaved,
selectedLibraryInstances,
setSelectedLibraryInstances,
toggleLibraryInstanceSelection,
@@ -100,40 +131,35 @@ const visibleInstanceGroups = computed(() =>
),
)
const visibleCustomGroups = computed(() =>
const visibleReorderableGroups = computed(() =>
displayState.value.group === 'Group'
? visibleInstanceGroups.value.filter(
(group) => group.id !== FAVORITES_GROUP_ID && group.id !== 'group:none',
)
? visibleInstanceGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID)
: [],
)
const visibleFavoritesGroup = computed(() =>
visibleInstanceGroups.value.find((group) => group.id === FAVORITES_GROUP_ID),
)
const visibleUngroupedGroup = computed(() =>
visibleInstanceGroups.value.find((group) => group.id === 'group:none'),
)
const draggableCustomGroups = ref<InstanceGroupType[]>([])
const draggableGroups = ref<InstanceGroupType[]>([])
const libraryGroupsContainer = ref<HTMLElement>()
const isDraggingGroup = ref(false)
const GROUP_REORDERING_CLASS = 'instance-group-reordering'
const canDragReorderGroups = computed(
() => !reorderingGroups.value && draggableCustomGroups.value.length > 1,
() => !reorderingGroups.value && draggableGroups.value.length > 1,
)
watch(
visibleCustomGroups,
visibleReorderableGroups,
(groups) => {
if (!isDraggingGroup.value) {
const previousGroupTops = getCustomGroupTops()
draggableCustomGroups.value = [...groups]
void nextTick(() => animateCustomGroupReorder(previousGroupTops))
const previousGroupTops = getReorderableGroupTops()
draggableGroups.value = [...groups]
void nextTick(() => animateGroupReorder(previousGroupTops))
}
},
{ immediate: true },
)
function getCustomGroupTops() {
function getReorderableGroupTops() {
const groupTops = new Map<string, number>()
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
'[data-instance-group-reorder-id]',
@@ -149,7 +175,7 @@ function getCustomGroupTops() {
return groupTops
}
function animateCustomGroupReorder(previousGroupTops: Map<string, number>) {
function animateGroupReorder(previousGroupTops: Map<string, number>) {
if (
previousGroupTops.size === 0 ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -185,10 +211,10 @@ function onGroupDragEnd() {
isDraggingGroup.value = false
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
const currentGroupIds = visibleCustomGroups.value.map((group) => group.id)
const orderedGroupIds = draggableCustomGroups.value.map((group) => group.id)
const currentGroupIds = visibleReorderableGroups.value.map((group) => group.id)
const orderedGroupIds = draggableGroups.value.map((group) => group.id)
if (orderedGroupIds.every((groupId, index) => groupId === currentGroupIds[index])) {
draggableCustomGroups.value = [...visibleCustomGroups.value]
draggableGroups.value = [...visibleReorderableGroups.value]
return
}
@@ -259,6 +285,10 @@ 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
@@ -313,7 +343,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
</div>
<Draggable
:list="draggableCustomGroups"
:list="draggableGroups"
class="flex flex-col"
item-key="id"
:disabled="!canDragReorderGroups"
@@ -341,6 +371,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
>
<InstanceGroup
:can-drag-reorder="canDragReorderGroups"
:hide-header="visibleInstanceGroups.length === 1"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
@@ -353,20 +384,6 @@ watch(selectedLibraryInstances, (selectedInstances) => {
</div>
</template>
</Draggable>
<div v-if="visibleUngroupedGroup" class="min-w-0">
<InstanceGroup
:hide-header="visibleInstanceGroups.length === 1"
:instance-group="visibleUngroupedGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === 'group:none' ? anchorInstance.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance('group:none', instanceId, shiftKey)
"
/>
</div>
</div>
<TransitionGroup
@@ -407,6 +424,12 @@ watch(selectedLibraryInstances, (selectedInstances) => {
:instances="currentDeleteInstances"
@delete="deleteInstance"
/>
<IconEditorModal
:ref="setIconEditorModal"
:instance-id="currentIconEditorInstance?.id"
:config="currentIconEditorInstance?.icon_config"
@saved="handleInstanceIconSaved"
/>
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
@@ -419,6 +442,14 @@ watch(selectedLibraryInstances, (selectedInstances) => {
</template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
<template #edit_icon> <EditIcon /> {{ formatMessage(messages.editIcon) }} </template>
<template #select_icon> <UploadIcon /> {{ formatMessage(messages.selectIcon) }} </template>
<template #replace_icon> <UploadIcon /> {{ formatMessage(messages.replaceIcon) }} </template>
<template #create_icon> <PaletteIcon /> {{ formatMessage(messages.createIcon) }} </template>
<template #edit_created_icon>
<PaletteIcon /> {{ formatMessage(messages.editCreatedIcon) }}
</template>
<template #remove_icon> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
<template #duplicate>
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
</template>
@@ -2,16 +2,25 @@
import { ArrowDownIcon, ArrowUpIcon, EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import { defineMessages, IconButton, useVIntl } from '@modrinth/ui'
defineProps<{
deleting?: boolean
canMoveDown: boolean
canMoveUp: boolean
onAddToGroup: () => void
onDeleteGroup: () => void
onEditGroupName: () => void
onMoveDown: () => void
onMoveUp: () => void
}>()
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()
@@ -64,6 +73,7 @@ const messages = defineMessages({
<ArrowDownIcon />
</IconButton>
<IconButton
v-if="showEdit"
v-tooltip="formatMessage(messages.editGroupName)"
:label="formatMessage(messages.editGroupName)"
type="quiet"
@@ -82,6 +92,7 @@ const messages = defineMessages({
<SquarePlusIcon />
</IconButton>
<IconButton
v-if="showDelete"
v-tooltip="formatMessage(messages.deleteGroup)"
:label="formatMessage(messages.deleteGroup)"
type="quiet"
@@ -21,7 +21,7 @@ import {
} from '@modrinth/ui'
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
import type {
@@ -29,6 +29,7 @@ import type {
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
@@ -48,6 +49,8 @@ const props = withDefaults(
)
const { formatMessage } = useVIntl()
const appSettings = useAppSettings()
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
const { addNotification } = injectNotificationManager()
const {
isSectionCollapsed,
@@ -84,6 +87,9 @@ 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),
@@ -383,7 +389,7 @@ onMounted(startInstanceGridResizeObserver)
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': isCustomGroup && canDragReorder,
'instance-group-reorder-handle': isReorderableGroup && canDragReorder,
}"
>
<div
@@ -442,7 +448,7 @@ onMounted(startInstanceGridResizeObserver)
</div>
<div class="min-w-0 flex-1" />
<GroupActionButtons
v-if="isCustomGroup"
v-if="isCustomGroup || isUngrouped"
:can-move-down="canMoveGroupDown(instanceGroup.id)"
:can-move-up="canMoveGroupUp(instanceGroup.id)"
:deleting="deletingGroup"
@@ -451,6 +457,8 @@ onMounted(startInstanceGridResizeObserver)
: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
@@ -470,7 +478,12 @@ onMounted(startInstanceGridResizeObserver)
<div ref="instanceGridContent">
<TransitionGroup
tag="section"
class="grid min-h-[45px] w-full grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))] max-xl:grid-cols-[repeat(auto-fill,minmax(min(8rem,100%),1fr))] gap-3 overflow-y-auto scroll-smooth"
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"
@@ -2,6 +2,7 @@
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'
@@ -16,6 +17,8 @@ const props = withDefaults(
)
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)
@@ -23,30 +26,40 @@ const versionRef = ref<HTMLElement | null>(null)
<template>
<div
class="relative flex w-full min-w-0 select-none flex-col items-start justify-end gap-3 overflow-clip rounded-[20px] border border-solid bg-surface-3 p-3 text-left transition-all"
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 aspect-square min-w-full shrink-0 items-center overflow-clip rounded-2xl"
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 !rounded-2xl outline-none"
class="pointer-events-none outline-none"
:class="compactMode ? '!rounded-lg' : '!rounded-2xl'"
size="100%"
:src="iconSrc"
:tint-by="instance.id"
alt=""
no-shadow
/>
<slot name="loading" />
<div class="absolute bottom-1.5 right-1.5 z-[1] flex size-12 items-center justify-center">
<slot name="leading" />
<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">
<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)"
@@ -62,6 +75,6 @@ const versionRef = ref<HTMLElement | null>(null)
{{ instance.loader }} {{ instance.game_version }}
</p>
</div>
<slot name="overlay" />
<slot name="overlay" :compact="compactMode" />
</div>
</template>
@@ -9,13 +9,13 @@ 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'
import { handleSevereError } from '@/store/error.js'
type ProcessEvent = 'installing' | 'launched' | 'finished'
@@ -285,7 +285,7 @@ onMounted(() => {
@mouseenter="checkProcess"
@pointerdown="handlePointerDown"
>
<template #loading>
<template #loading="{ compact }">
<div
v-if="loadingIndicatorVisible"
class="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center"
@@ -293,13 +293,17 @@ onMounted(() => {
<div class="absolute inset-0 bg-surface-1 opacity-30" />
<SpinnerIcon
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
class="relative size-[30%] animate-spin text-contrast"
class="relative animate-spin text-contrast"
:class="compact ? 'size-5' : 'size-[30%]'"
tabindex="-1"
/>
</div>
</template>
<template #leading>
<div class="relative flex size-12 shrink-0 items-center justify-center">
<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"
@@ -307,7 +311,7 @@ onMounted(() => {
:label="formatMessage(messages.stop)"
type="colored"
color="red"
size="lg"
:size="compact ? 'md' : 'lg'"
@click="(e) => stop(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
@@ -325,7 +329,7 @@ onMounted(() => {
:label="formatMessage(messages.repair)"
type="colored"
color="brand"
size="lg"
: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)"
>
@@ -342,7 +346,7 @@ onMounted(() => {
:label="formatMessage(messages.play)"
type="colored"
color="brand"
size="lg"
: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"
@@ -352,18 +356,21 @@ onMounted(() => {
</div>
</div>
</template>
<template #overlay>
<template #overlay="{ compact }">
<button
type="button"
class="selection-button group/selection absolute right-2 top-1.5 z-[2] flex size-[50px] cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
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 size-[24px] 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="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':
@@ -15,6 +15,8 @@ const { formatMessage } = useVIntl()
const messages = defineMessages({
name: { id: 'app.library.sort.name', defaultMessage: 'Name' },
loaderSort: { id: 'app.library.sort.loader', defaultMessage: 'Loader' },
gameVersionSort: { id: 'app.library.sort.game-version', defaultMessage: 'Game version' },
lastPlayed: { id: 'app.library.sort.last-played', defaultMessage: 'Last played' },
hoursPlayed: { id: 'app.library.sort.hours-played', defaultMessage: 'Hours played' },
dateCreated: { id: 'app.library.sort.date-created', defaultMessage: 'Date created' },
@@ -34,6 +36,8 @@ const sortLabels = {
'Hours played': messages.hoursPlayed,
'Date created': messages.dateCreated,
'Date modified': messages.dateModified,
Loader: messages.loaderSort,
'Game version': messages.gameVersionSort,
}
const groupLabels = {
@@ -60,6 +64,7 @@ const groupOptions: ComboboxOption<LibraryGroupBy>[] = libraryGroupOptions.map((
class="w-max"
:options="sortOptions"
:show-icon-in-selected="false"
:max-height="320"
dropdown-min-width="160px"
>
<template #prefix>
@@ -1,11 +1,13 @@
import type { Labrinth } from '@modrinth/api-client'
import { formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
import { useEventListener, useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import {
computed,
inject,
type InjectionKey,
nextTick,
provide,
type Ref,
ref,
@@ -13,10 +15,11 @@ import {
watchEffect,
} from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3_many } from '@/helpers/cache.js'
import { toError } from '@/helpers/errors'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, remove } from '@/helpers/instance'
import { edit, edit_icon, remove } from '@/helpers/instance'
import {
create_group as createInstanceGroup,
delete_group as deleteInstanceGroup,
@@ -28,7 +31,7 @@ import {
set_group_memberships as setInstanceGroupMemberships,
set_group_order as setInstanceGroupOrder,
} from '@/helpers/instance-groups'
import type { GameInstance } from '@/helpers/types'
import type { GameInstance, InstanceIconConfig } from '@/helpers/types'
export const librarySortOptions = [
'Name',
@@ -36,6 +39,8 @@ export const librarySortOptions = [
'Hours played',
'Date created',
'Date modified',
'Loader',
'Game version',
] as const
export const libraryGroupOptions = [
@@ -100,6 +105,10 @@ type ConfirmDeleteModal = {
show: () => void
}
type IconEditorModal = {
show: () => void
}
type ContextMenuSelection = {
option: string
item: InstanceCard
@@ -153,17 +162,25 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
)
const currentContextGroupId = ref<string | null>(null)
const confirmDeleteModal = ref<ConfirmDeleteModal | null>(null)
const iconEditorModal = ref<IconEditorModal | null>(null)
const currentIconEditorInstanceId = ref<string | null>(null)
const currentIconEditorInstance = computed(
() =>
instances.value.find((instance) => instance.id === currentIconEditorInstanceId.value) ?? null,
)
const displayState = useStorage<{
group: LibraryGroupBy
sortBy: LibrarySort
collapsedGroups: string[]
ungroupedGroupPosition: number
}>(
'Instances-grid-display-state',
{
group: 'Group',
sortBy: 'Last played',
collapsedGroups: [],
ungroupedGroupPosition: Number.MAX_SAFE_INTEGER,
},
localStorage,
{ mergeDefaults: true },
@@ -209,10 +226,15 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
return a.name.localeCompare(b.name)
})
})
const groupInstancesModalGroup = computed(
() =>
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null,
)
const groupInstancesModalGroup = computed(() => {
if (groupInstancesModalGroupId.value === 'group:none') {
return { id: 'group:none', name: 'None' }
}
return (
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null
)
})
const groupInstances = computed(() => {
const query = groupInstancesSearch.value.trim().toLowerCase()
@@ -227,8 +249,23 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const customLibraryGroups = computed(() =>
libraryGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID),
)
const customGroupOrder = computed(
() => new Map(customLibraryGroups.value.map((group, index) => [group.id, index])),
const orderedLibraryGroupIds = computed(() => {
const groupIds = customLibraryGroups.value.map((group) => group.id)
const storedUngroupedGroupPosition = displayState.value.ungroupedGroupPosition
const ungroupedGroupPosition = Math.min(
Math.max(
Number.isFinite(storedUngroupedGroupPosition)
? Math.trunc(storedUngroupedGroupPosition)
: Number.MAX_SAFE_INTEGER,
0,
),
groupIds.length,
)
groupIds.splice(ungroupedGroupPosition, 0, 'group:none')
return groupIds
})
const libraryGroupOrder = computed(
() => new Map(orderedLibraryGroupIds.value.map((groupId, index) => [groupId, index])),
)
const refreshGroups = async () => {
@@ -328,6 +365,18 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
case 'Name':
visibleInstances.sort((a, b) => a.name.localeCompare(b.name))
break
case 'Loader':
visibleInstances.sort((a, b) =>
formatLoader(formatMessage, a.loader).localeCompare(
formatLoader(formatMessage, b.loader),
),
)
break
case 'Game version':
visibleInstances.sort((a, b) =>
a.game_version.localeCompare(b.game_version, undefined, { numeric: true }),
)
break
case 'Last played':
visibleInstances.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
break
@@ -436,11 +485,9 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
if (a.id === b.id) return 0
if (a.id === FAVORITES_GROUP_ID) return -1
if (b.id === FAVORITES_GROUP_ID) return 1
if (a.id === 'group:none') return 1
if (b.id === 'group:none') return -1
const aOrder = customGroupOrder.value.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bOrder = customGroupOrder.value.get(b.id) ?? Number.MAX_SAFE_INTEGER
const aOrder = libraryGroupOrder.value.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bOrder = libraryGroupOrder.value.get(b.id) ?? Number.MAX_SAFE_INTEGER
return aOrder - bOrder || a.key.localeCompare(b.key) || a.id.localeCompare(b.id)
})
}
@@ -701,13 +748,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const openGroupInstancesModal = (groupId: string) => {
const group = libraryGroups.value.find((candidate) => candidate.id === groupId)
if (!group) return
if (!group && groupId !== 'group:none') return
groupInstancesModalGroupId.value = groupId
groupInstancesSearch.value = ''
selectedGroupInstanceIds.value = new Set(
instances.value
.filter((instance) => instance.group_ids.includes(groupId))
.filter((instance) =>
groupId === 'group:none'
? instance.group_ids.length === 0
: instance.group_ids.includes(groupId),
)
.map((instance) => instance.id),
)
isGroupInstancesModalOpen.value = true
@@ -722,6 +773,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const selectedIds = new Set(selectedGroupInstanceIds.value)
if (selectedIds.has(instanceId)) {
if (groupInstancesModalGroupId.value === 'group:none') return
selectedIds.delete(instanceId)
} else {
selectedIds.add(instanceId)
@@ -734,15 +786,20 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const groupId = groupInstancesModalGroupId.value
if (!groupId || savingGroupInstances.value) return false
const changedInstances = instances.value.filter(
(instance) =>
instance.group_ids.includes(groupId) !== selectedGroupInstanceIds.value.has(instance.id),
)
const isUngrouped = groupId === 'group:none'
const changedInstances = instances.value.filter((instance) => {
const isSelected = selectedGroupInstanceIds.value.has(instance.id)
return isUngrouped
? isSelected && instance.group_ids.length > 0
: instance.group_ids.includes(groupId) !== isSelected
})
const operations = changedInstances.map((instance) => {
const shouldIncludeGroup = selectedGroupInstanceIds.value.has(instance.id)
const nextGroupIds = shouldIncludeGroup
? [...instance.group_ids, groupId]
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
const nextGroupIds = isUngrouped
? []
: shouldIncludeGroup
? [...instance.group_ids, groupId]
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
return {
instance,
@@ -964,14 +1021,16 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const canMoveGroupUp = (groupId: string) =>
!reorderingGroups.value &&
customLibraryGroups.value.findIndex((group) => group.id === groupId) > 0
orderedLibraryGroupIds.value.findIndex((orderedGroupId) => orderedGroupId === groupId) > 0
const canMoveGroupDown = (groupId: string) => {
const groupIndex = customLibraryGroups.value.findIndex((group) => group.id === groupId)
const groupIndex = orderedLibraryGroupIds.value.findIndex(
(orderedGroupId) => orderedGroupId === groupId,
)
return (
!reorderingGroups.value &&
groupIndex >= 0 &&
groupIndex < customLibraryGroups.value.length - 1
groupIndex < orderedLibraryGroupIds.value.length - 1
)
}
@@ -979,35 +1038,48 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
if (reorderingGroups.value) return false
const previousGroups = libraryGroups.value
const previousUngroupedGroupPosition = displayState.value.ungroupedGroupPosition
const customGroupsById = new Map(customLibraryGroups.value.map((group) => [group.id, group]))
const reorderableGroupIds = new Set([...customGroupsById.keys(), 'group:none'])
const orderedGroupIdSet = new Set(orderedGroupIds)
if (
orderedGroupIdSet.size !== orderedGroupIds.length ||
orderedGroupIds.some((groupId) => !customGroupsById.has(groupId))
orderedGroupIds.some((groupId) => !reorderableGroupIds.has(groupId))
) {
return false
}
const orderedGroups = orderedGroupIds.map((groupId) => customGroupsById.get(groupId)!)
let orderedGroupIndex = 0
const reorderedCustomGroups = customLibraryGroups.value.map((group) =>
orderedGroupIdSet.has(group.id) ? orderedGroups[orderedGroupIndex++] : group,
const reorderedGroupIds = orderedLibraryGroupIds.value.map((groupId) =>
orderedGroupIdSet.has(groupId) ? orderedGroupIds[orderedGroupIndex++] : groupId,
)
if (reorderedCustomGroups.every((group, index) => group === customLibraryGroups.value[index])) {
if (
reorderedGroupIds.every((groupId, index) => groupId === orderedLibraryGroupIds.value[index])
) {
return false
}
const reorderedCustomGroups = reorderedGroupIds
.filter((groupId) => groupId !== 'group:none')
.map((groupId) => customGroupsById.get(groupId)!)
const customGroupOrderChanged = reorderedCustomGroups.some(
(group, index) => group !== customLibraryGroups.value[index],
)
const favoriteGroups = previousGroups.filter((group) => group.id === FAVORITES_GROUP_ID)
libraryGroups.value = [...favoriteGroups, ...reorderedCustomGroups]
displayState.value.ungroupedGroupPosition = reorderedGroupIds.indexOf('group:none')
reorderingGroups.value = true
try {
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
if (customGroupOrderChanged) {
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
}
return true
} catch (error) {
libraryGroups.value = previousGroups
displayState.value.ungroupedGroupPosition = previousUngroupedGroupPosition
handleError(toError(error))
await refreshGroups()
return false
@@ -1017,7 +1089,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
}
const moveGroup = async (groupId: string, direction: -1 | 1) => {
const orderedGroupIds = customLibraryGroups.value.map((group) => group.id)
const orderedGroupIds = [...orderedLibraryGroupIds.value]
const groupIndex = orderedGroupIds.indexOf(groupId)
const targetIndex = groupIndex + direction
@@ -1043,6 +1115,47 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
await install_duplicate_instance(instanceId).catch((error) => handleError(toError(error)))
}
const selectInstanceIcon = async (item: InstanceCard) => {
const iconPath = await openDialog({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
},
],
})
if (!iconPath) return
try {
await edit_icon(item.instance.id, iconPath)
trackEvent('InstanceSetIcon')
} catch (error) {
handleError(toError(error))
}
}
const removeInstanceIcon = async (item: InstanceCard) => {
try {
await edit_icon(item.instance.id, null)
trackEvent('InstanceRemoveIcon')
} catch (error) {
handleError(toError(error))
}
}
const openInstanceIconEditor = async (item: InstanceCard) => {
currentIconEditorInstanceId.value = item.instance.id
await nextTick()
iconEditorModal.value?.show()
trackEvent(item.instance.icon_config ? 'InstanceEditCreatedIcon' : 'InstanceCreateIcon')
}
const handleInstanceIconSaved = (_iconPath: string, _config: InstanceIconConfig) => {
trackEvent('InstanceSaveCreatedIcon')
}
const handleInstanceContextMenu = (
event: MouseEvent,
item: InstanceCard,
@@ -1069,6 +1182,14 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
{ name: 'duplicate' },
{ name: 'open' },
{ name: 'copy' },
{
name: 'edit_icon',
children: [
{ name: item.instance.icon_path ? 'replace_icon' : 'select_icon' },
{ name: item.instance.icon_config ? 'edit_created_icon' : 'create_icon' },
...(item.instance.icon_path ? [{ name: 'remove_icon' }] : []),
],
},
...(currentContextGroupId.value
? [{ name: 'remove_from_group' }, { type: 'divider' }]
: [{ type: 'divider' }]),
@@ -1113,6 +1234,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
case 'edit':
await item.seeInstance()
break
case 'select_icon':
case 'replace_icon':
await selectInstanceIcon(item)
break
case 'create_icon':
case 'edit_created_icon':
await openInstanceIconEditor(item)
break
case 'remove_icon':
await removeInstanceIcon(item)
break
case 'duplicate':
if (item.instance.install_stage === 'installed') {
await duplicateInstance(item.instance.id)
@@ -1175,6 +1307,8 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
canCreateGroup,
instanceOptions,
confirmDeleteModal,
iconEditorModal,
currentIconEditorInstance,
currentDeleteInstances,
isSectionCollapsed,
setSectionCollapsed,
@@ -1206,6 +1340,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
deleteInstance,
handleInstanceContextMenu,
handleInstanceOption,
handleInstanceIconSaved,
}
}
@@ -10,8 +10,8 @@ import {
import { Admonition, Button, ButtonLink, Collapsible, IconButton, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { handleSevereError } from '@/composables/use-error.js'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
import { findMinecraftAuthError, type MinecraftAuthError } from './minecraft-auth-errors'
@@ -63,9 +63,9 @@ import { inject, type Ref, ref } from 'vue'
import steveImage from '@/assets/steve-look-up-left.webp'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
const { formatMessage } = useVIntl()
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
@@ -36,16 +36,16 @@ import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.
import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultInstanceSettings.vue'
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get, set } from '@/helpers/settings.ts'
import {
appSettingsModalContextKey,
type UnsavedChangesController,
} from '@/providers/app-settings-modal'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
import { useTheming } from '@/store/state'
// TODO: Apply COMPONENT_STRUCTURE.md here and extract out common setting option components
const themeStore = useTheming()
const appSettings = useAppSettings()
const { formatMessage } = useVIntl()
@@ -157,7 +157,9 @@ const tabs = [
},
]
const availableTabs = computed(() => tabs.filter((tab) => !tab.developerOnly || themeStore.devMode))
const availableTabs = computed(() =>
tabs.filter((tab) => !tab.developerOnly || appSettings.devMode),
)
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const unsavedChangesPopup = ref<{ nudge: () => void } | null>(null)
@@ -170,10 +172,19 @@ const modifiedUnsavedChangesState = computed(
() => unsavedChangesController.value?.getModified() ?? emptyUnsavedChangesState,
)
const savingUnsavedChanges = computed(() => unsavedChangesController.value?.isSaving() ?? false)
const hasUnsavedChanges = computed(() => unsavedChangesController.value?.hasChanges() ?? false)
const hasUnsavedChanges = computed(
() =>
(unsavedChangesController.value?.hasChanges() ?? false) ||
(unsavedChangesController.value?.isSaving() ?? false),
)
function canLeaveCurrentTab(): boolean {
if (!unsavedChangesController.value?.hasChanges()) return true
if (
!unsavedChangesController.value?.hasChanges() &&
!unsavedChangesController.value?.isSaving()
) {
return true
}
unsavedChangesPopup.value?.nudge()
return false
}
@@ -233,8 +244,8 @@ function devModeCount() {
if (devModeCounter.value > 5) {
const selectedTab = modal.value ? availableTabs.value[modal.value.selectedTab] : undefined
themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode
appSettings.devMode = !appSettings.devMode
settings.value.developer_mode = !!appSettings.devMode
devModeCounter.value = 0
if (modal.value) {
@@ -298,7 +309,7 @@ const messages = defineMessages({
<ProgressBar :progress="progress" />
</template>
</div>
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
<p v-if="appSettings.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }}
</p>
<div class="flex items-center gap-3">
@@ -306,8 +317,8 @@ const messages = defineMessages({
:aria-label="formatMessage(messages.developerModeButtonLabel)"
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{
'text-brand': themeStore.devMode,
'text-secondary': !themeStore.devMode,
'text-brand': appSettings.devMode,
'text-secondary': !appSettings.devMode,
}"
@click="devModeCount"
>
@@ -154,7 +154,7 @@ defineExpose({ show, hide })
:disable-close="applying"
class="!overflow-hidden !rounded-[20px]"
>
<div class="grid h-[384px] w-[768px] max-w-full grid-cols-2">
<div class="grid w-[768px] max-w-full grid-cols-2">
<section class="flex min-w-0 flex-col gap-6 bg-surface-3 p-8">
<div
class="flex h-8 w-fit items-center rounded-full border border-solid border-brand bg-brand-highlight px-2.5 text-sm font-base leading-5 text-brand"
@@ -1,5 +1,6 @@
<template>
<AccountSocialSettings
ref="socialSettings"
:get-blocked-users="get_blocked_users"
:get-users="getUsers"
:unblock-user="unblock_user"
@@ -9,10 +10,29 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountSocialSettings, injectModrinthClient } from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { get_blocked_users, unblock_user } from '@/helpers/users'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const client = injectModrinthClient()
const settingsModal = inject(appSettingsModalContextKey, null)
const socialSettings = ref<InstanceType<typeof AccountSocialSettings> | null>(null)
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => socialSettings.value?.hasChanges ?? false,
getOriginal: () => socialSettings.value?.originalState ?? {},
getModified: () => socialSettings.value?.modifiedState ?? {},
isSaving: () => socialSettings.value?.saving ?? false,
reset: () => socialSettings.value?.reset(),
save: () => socialSettings.value?.save(),
})
})
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
return client.labrinth.users_v2.getMultiple(userIds)
@@ -1,108 +1,154 @@
<script setup lang="ts">
import { defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import {
AppearanceSettingsLayout,
injectAuth,
injectUserPreferences,
provideAppearanceSettings,
useSavable,
} from '@modrinth/ui'
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { type ColorTheme, useTheme } from '@/composables/use-theme.ts'
import { type AppSettings, get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme } from '@/store/theme.ts'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const messages = defineMessages({
colorThemeTitle: {
id: 'app.appearance-settings.color-theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'app.appearance-settings.color-theme.description',
defaultMessage: 'Choose the color theme used by Modrinth App.',
},
advancedRenderingTitle: {
id: 'app.appearance-settings.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'app.appearance-settings.advanced-rendering.description',
defaultMessage:
'Enable visual effects such as background blur. This may reduce performance without hardware acceleration.',
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'System window frame',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage:
"Use your operating system's title bar and window controls. Requires an app restart.",
},
})
const os = ref(await getOS())
const theme = useTheme()
const auth = injectAuth()
const { updatePreferences } = injectUserPreferences()
const settingsModal = inject(appSettingsModalContextKey, null)
const os = await getOS()
const settings = ref(await get())
const themeOptions = computed(() =>
themeStore
.getThemeOptions()
.filter((theme) => theme !== 'retro' || themeStore.devMode || settings.value.theme === 'retro'),
type AppearanceSettingsState = {
theme: ColorTheme
syncAcrossDevices: boolean
advancedRendering: boolean
nativeDecorations: boolean
}
function getAppearanceSettingsState(settings: AppSettings): AppearanceSettingsState {
return {
theme: settings.theme,
syncAcrossDevices: settings.sync_theme_across_devices,
advancedRendering: settings.advanced_rendering,
nativeDecorations: settings.native_decorations,
}
}
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => getAppearanceSettingsState(settings.value),
async (appearanceChanges) => {
const value = current.value
if (
value.syncAcrossDevices &&
auth.user.value &&
(appearanceChanges.theme !== undefined || appearanceChanges.syncAcrossDevices !== undefined)
) {
await updatePreferences({
appearance: value.theme === 'system' ? { auto: true } : { auto: false, theme: value.theme },
})
}
const nextSettings: AppSettings = {
...settings.value,
theme: value.theme,
sync_theme_across_devices: value.syncAcrossDevices,
advanced_rendering: value.advancedRendering,
native_decorations: value.nativeDecorations,
}
await set(nextSettings)
settings.value = nextSettings
theme.preferred = value.theme
theme.syncAcrossDevices = value.syncAcrossDevices
theme.advancedRendering = value.advancedRendering
},
)
const themeOptions = computed(() =>
theme.options.filter(
(option) =>
option !== 'retro' || settings.value.developer_mode || current.value.theme === 'retro',
),
)
function setTheme(value: ColorTheme): void {
current.value.theme = value
}
function setSyncAcrossDevices(enabled: boolean): void {
current.value.syncAcrossDevices = enabled
}
function setAdvancedRendering(enabled: boolean): void {
current.value.advancedRendering = enabled
}
function setNativeDecorations(enabled: boolean): void {
current.value.nativeDecorations = enabled
}
watch(
settings,
async () => {
await set(settings.value)
[() => current.value.theme, () => saved.value.theme],
([selectedTheme, savedTheme]) => {
theme.preview = selectedTheme === savedTheme ? null : selectedTheme
},
{ deep: true },
{ immediate: true },
)
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.colorThemeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.colorThemeDescription) }}</p>
async function saveAppearanceSettings(): Promise<void> {
try {
await save()
} catch {
return
}
}
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeOptions"
system-theme-color="system"
/>
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => hasChanges.value,
getOriginal: () => saved.value,
getModified: () => changes.value,
isSaving: () => saving.value,
reset,
save: saveAppearanceSettings,
})
})
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.advancedRenderingTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.advancedRenderingDescription) }}
</p>
</div>
onBeforeUnmount(() => {
theme.preview = null
settingsModal?.registerUnsavedChangesController(null)
})
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
provideAppearanceSettings({
deferPersistence: true,
theme: {
current: computed(() => current.value.theme),
options: themeOptions,
system: computed(() => theme.native),
set: setTheme,
syncAcrossDevices: {
value: computed(() => current.value.syncAcrossDevices),
set: setSyncAcrossDevices,
},
syncDisabled: computed(() => !auth.user.value),
},
advancedRendering: {
value: computed(() => current.value.advancedRendering),
set: setAdvancedRendering,
},
nativeDecorations:
os !== 'MacOS'
? {
value: computed(() => current.value.nativeDecorations),
set: setNativeDecorations,
}
"
/>
</div>
: undefined,
updatePreferences,
})
</script>
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.nativeDecorationsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.nativeDecorationsDescription) }}</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
<template>
<AppearanceSettingsLayout />
</template>
@@ -1,20 +1,48 @@
<script setup lang="ts">
import { defineMessages, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import {
defineMessages,
injectAuth,
injectUserPreferences,
Toggle,
useSavable,
useVIntl,
} from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
import {
DEFAULT_FEATURE_FLAGS,
type FeatureFlag,
useAppSettings,
} from '@/composables/use-app-settings.ts'
import { type AppSettings, get, set } from '@/helpers/settings.ts'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const themeStore = useTheming()
const appSettings = useAppSettings()
const { formatMessage } = useVIntl()
const auth = injectAuth()
const { updatePreferences } = injectUserPreferences()
const settingsModal = inject(appSettingsModalContextKey, null)
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const compactInstanceCardsFlag: FeatureFlag = 'compact_instance_cards'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
const messages = defineMessages({
syncAcrossDevicesTitle: {
id: 'app.behavior-settings.sync-across-devices.title',
defaultMessage: 'Sync behavior across devices',
},
syncAcrossDevicesDescription: {
id: 'app.behavior-settings.sync-across-devices.description',
defaultMessage:
"Use these behavior settings everywhere you're signed in. Turn this off to keep separate settings on this device.",
},
syncAcrossDevicesSignedOutTooltip: {
id: 'app.behavior-settings.sync-across-devices.signed-out-tooltip',
defaultMessage: 'Sign into a Modrinth account to sync settings.',
},
startupAndNavigationTitle: {
id: 'app.behavior-settings.startup-and-navigation.title',
defaultMessage: 'Startup and navigation',
@@ -60,6 +88,14 @@ const messages = defineMessages({
defaultMessage:
'Show recently played worlds or instances in the "Jump in" section on the Home page.',
},
compactModeTitle: {
id: 'app.appearance-settings.compact-mode.title',
defaultMessage: 'Compact mode',
},
compactModeDescription: {
id: 'app.appearance-settings.compact-mode.description',
defaultMessage: 'Display library instances in a compact row layout.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
@@ -96,18 +132,143 @@ const messages = defineMessages({
},
})
const settings = ref(await get())
type BehaviorSettingsState = {
syncBehaviorAcrossDevices: boolean
minimizeApp: boolean
hideRightSidebar: boolean
showJumpIn: boolean
compactInstanceCards: boolean
showPlayTime: boolean
hideNametag: boolean
warnOnUnknownModpacks: boolean
skipNonEssentialWarnings: boolean
}
watch(
settings,
const persistedSettings = ref(await get())
function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState {
return {
syncBehaviorAcrossDevices: settings.sync_behavior_across_devices,
minimizeApp: settings.hide_on_process_start,
hideRightSidebar: settings.toggle_sidebar,
showJumpIn: settings.feature_flags[worldsInHomeFlag] ?? DEFAULT_FEATURE_FLAGS[worldsInHomeFlag],
compactInstanceCards:
settings.feature_flags[compactInstanceCardsFlag] ??
DEFAULT_FEATURE_FLAGS[compactInstanceCardsFlag],
showPlayTime:
settings.feature_flags[showPlayTimeFlag] ?? DEFAULT_FEATURE_FLAGS[showPlayTimeFlag],
hideNametag: settings.hide_nametag_skins_page,
warnOnUnknownModpacks: !(
settings.feature_flags[skipUnknownPackWarningFlag] ??
DEFAULT_FEATURE_FLAGS[skipUnknownPackWarningFlag]
),
skipNonEssentialWarnings:
settings.feature_flags[skipNonEssentialWarningsFlag] ??
DEFAULT_FEATURE_FLAGS[skipNonEssentialWarningsFlag],
}
}
const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
() => getBehaviorSettingsState(persistedSettings.value),
async () => {
await set(settings.value)
const value = current.value
if (value.syncBehaviorAcrossDevices && auth.user.value) {
await updatePreferences({
behavior: {
minimize_app: value.minimizeApp,
hide_right_sidebar: value.hideRightSidebar,
show_jump_in: value.showJumpIn,
compact_instance_cards: value.compactInstanceCards,
show_play_time: value.showPlayTime,
hide_nametag: value.hideNametag,
warn_on_unknown_modpacks: value.warnOnUnknownModpacks,
skip_non_essential_warnings: value.skipNonEssentialWarnings,
},
})
}
const nextSettings: AppSettings = {
...persistedSettings.value,
sync_behavior_across_devices: value.syncBehaviorAcrossDevices,
hide_on_process_start: value.minimizeApp,
toggle_sidebar: value.hideRightSidebar,
hide_nametag_skins_page: value.hideNametag,
feature_flags: {
...persistedSettings.value.feature_flags,
[worldsInHomeFlag]: value.showJumpIn,
[compactInstanceCardsFlag]: value.compactInstanceCards,
[showPlayTimeFlag]: value.showPlayTime,
[skipUnknownPackWarningFlag]: !value.warnOnUnknownModpacks,
[skipNonEssentialWarningsFlag]: value.skipNonEssentialWarnings,
},
}
await set(nextSettings)
persistedSettings.value = nextSettings
appSettings.setBehaviorSyncAcrossDevices(value.syncBehaviorAcrossDevices)
appSettings.toggleSidebar = value.hideRightSidebar
appSettings.hideNametagSkinsPage = value.hideNametag
appSettings.featureFlags[worldsInHomeFlag] = value.showJumpIn
appSettings.featureFlags[compactInstanceCardsFlag] = value.compactInstanceCards
appSettings.featureFlags[showPlayTimeFlag] = value.showPlayTime
appSettings.featureFlags[skipUnknownPackWarningFlag] = !value.warnOnUnknownModpacks
appSettings.featureFlags[skipNonEssentialWarningsFlag] = value.skipNonEssentialWarnings
},
{ deep: true },
)
async function saveBehaviorSettings(): Promise<void> {
try {
await save()
} catch {
return
}
}
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => hasChanges.value,
getOriginal: () => saved.value,
getModified: () => changes.value,
isSaving: () => saving.value,
reset,
save: saveBehaviorSettings,
})
})
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
</script>
<template>
<section>
<section class="border-0 border-b border-solid border-divider pb-6">
<div class="flex items-center justify-between gap-4">
<div>
<h2 id="sync-behavior-across-devices-label" class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.syncAcrossDevicesTitle) }}
</h2>
<p class="m-0 mt-1 text-secondary">
{{ formatMessage(messages.syncAcrossDevicesDescription) }}
</p>
</div>
<span
v-tooltip="
!auth.user.value ? formatMessage(messages.syncAcrossDevicesSignedOutTooltip) : undefined
"
class="inline-flex shrink-0"
>
<Toggle
id="sync-behavior-across-devices"
:model-value="Boolean(auth.user.value) && current.syncBehaviorAcrossDevices"
:disabled="!auth.user.value"
aria-labelledby="sync-behavior-across-devices-label"
@update:model-value="current.syncBehaviorAcrossDevices = $event"
/>
</span>
</div>
</section>
<section class="mt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.startupAndNavigationTitle) }}
</h2>
@@ -121,7 +282,7 @@ watch(
{{ formatMessage(messages.minimizeLauncherDescription) }}
</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
<Toggle id="minimize-launcher" v-model="current.minimizeApp" />
</div>
<div class="flex items-center justify-between gap-4">
@@ -131,16 +292,7 @@ watch(
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
</div>
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = !!e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
/>
<Toggle id="toggle-sidebar" v-model="current.hideRightSidebar" />
</div>
</div>
</section>
@@ -159,17 +311,17 @@ watch(
{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}
</p>
</div>
<Toggle
id="jump-back-into-worlds"
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
}
"
/>
<Toggle id="jump-back-into-worlds" v-model="current.showJumpIn" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.compactModeTitle) }}
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.compactModeDescription) }}</p>
</div>
<Toggle id="compact-mode" v-model="current.compactInstanceCards" />
</div>
<div class="flex items-center justify-between gap-4">
@@ -179,17 +331,7 @@ watch(
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
</div>
<Toggle
id="show-play-time"
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
<Toggle id="show-play-time" v-model="current.showPlayTime" />
</div>
<div class="flex items-center justify-between gap-4">
@@ -199,16 +341,7 @@ watch(
</h3>
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
</div>
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
<Toggle id="hide-nametag-skins-page" v-model="current.hideNametag" />
</div>
</div>
</section>
@@ -229,15 +362,7 @@ watch(
</div>
<Toggle
id="warn-before-installing-unknown-modpacks"
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
v-model="current.warnOnUnknownModpacks"
/>
</div>
@@ -250,17 +375,7 @@ watch(
{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}
</p>
</div>
<Toggle
id="skip-non-essential-warnings"
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
<Toggle id="skip-non-essential-warnings" v-model="current.skipNonEssentialWarnings" />
</div>
</div>
</section>
@@ -2,17 +2,20 @@
import { Button, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import {
DEFAULT_FEATURE_FLAGS,
type FeatureFlag,
useAppSettings,
} from '@/composables/use-app-settings.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const appSettings = useAppSettings()
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
const options = ref(Object.keys(DEFAULT_FEATURE_FLAGS) as FeatureFlag[])
function setFeatureFlag(key: string, value: boolean) {
themeStore.featureFlags[key] = value
function setFeatureFlag(key: FeatureFlag, value: boolean) {
appSettings.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
@@ -35,15 +38,15 @@ watch(
<div class="flex items-center gap-2">
<Button
type="quiet"
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
:disabled="appSettings.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</Button>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
:model-value="appSettings.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !appSettings.getFeatureFlag(option))"
/>
</div>
</div>
@@ -1,74 +1,35 @@
<template>
<SharedLanguageSettings ref="languageSettings" product="app" :persist-locale="persistLocale" />
</template>
<script setup lang="ts">
import {
Admonition,
AutoLink,
commonSettingsMessages,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
LOCALES,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { LanguageSettings as SharedLanguageSettings } from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import i18n from '@/i18n.config'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const { formatMessage } = useVIntl()
const settingsModal = inject(appSettingsModalContextKey, null)
const languageSettings = ref<InstanceType<typeof SharedLanguageSettings> | null>(null)
const platform = computed(() => formatMessage(languageSelectorMessages.platformApp))
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => languageSettings.value?.hasChanges ?? false,
getOriginal: () => languageSettings.value?.originalState ?? {},
getModified: () => languageSettings.value?.modifiedState ?? {},
isSaving: () => languageSettings.value?.saving ?? false,
reset: () => languageSettings.value?.reset(),
save: () => languageSettings.value?.save(),
})
})
const settings = ref(await get())
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
const $isChanging = ref(false)
async function onLocaleChange(newLocale: string) {
if (settings.value.locale === newLocale) return
$isChanging.value = true
try {
i18n.global.locale.value = newLocale
settings.value.locale = newLocale
} finally {
$isChanging.value = false
}
async function persistLocale(locale: string): Promise<void> {
const settings = await get()
if (settings.locale === locale) return
await set({ ...settings, locale })
}
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(commonSettingsMessages.language) }}
</h2>
<Admonition type="warning" class="mt-2 mb-4">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
</Admonition>
<p class="m-0 mb-4">
<IntlFormatted
:message-id="languageSelectorMessages.languagesDescription"
:values="{ platform }"
>
<template #~crowdin-link="{ children }">
<AutoLink to="https://translate.modrinth.com">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<LanguageSelector
:current-locale="settings.locale"
:locales="LOCALES"
:on-locale-change="onLocaleChange"
:is-changing="$isChanging"
/>
</template>
@@ -14,14 +14,14 @@ import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { purge_cache_types } from '@/helpers/cache.js'
import { get, set } from '@/helpers/settings.ts'
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const appSettings = useAppSettings()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
@@ -145,7 +145,7 @@ async function purgeCache() {
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
if (appSettings.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
@@ -210,11 +210,11 @@ async function findLauncherDir() {
</div>
<Toggle
id="always-show-copy-details"
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
:model-value="appSettings.getFeatureFlag(alwaysShowCopyDetailsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
const newValue = !appSettings.getFeatureFlag(alwaysShowCopyDetailsFlag)
appSettings.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
@@ -9,6 +9,7 @@ import { useQueryClient } from '@tanstack/vue-query'
import { type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
@@ -18,7 +19,6 @@ import {
} from '@/helpers/install'
import { list } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { useTheming } from '@/store/state'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
@@ -58,7 +58,7 @@ export function useSharedInstanceInviteHandler(
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
const themeStore = useTheming()
const appSettings = useAppSettings()
const displayedNotifications = new Set<string | number>()
const displayedNotificationKeys = new Set<string>()
const popupNotificationIds = new Set<string | number>()
@@ -121,7 +121,7 @@ export function useSharedInstanceInviteHandler(
(instance) => instance.shared_instance?.id === sharedInstanceId,
)
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
if (!existingInstance || appSettings.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install, creator)
return
}
@@ -28,12 +28,12 @@ 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 { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils'
import { handleSevereError } from '@/store/error'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -1,15 +1,23 @@
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import type { GameVersion } from '@modrinth/ui'
import { defineMessages, GAME_MODES, injectNotificationManager, useVIntl } from '@modrinth/ui'
import {
Accordion,
defineMessages,
GAME_MODES,
type GameVersion,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance'
import { get_all } from '@/helpers/process'
@@ -29,20 +37,19 @@ import {
start_join_singleplayer_world,
type WorldWithInstance,
} from '@/helpers/worlds.ts'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/theme.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
jumpIn: { id: 'app.home.jump-back-in.title', defaultMessage: 'Jump in' },
resize: { id: 'app.home.jump-back-in.resize', defaultMessage: 'Drag to resize' },
})
const props = defineProps<{
recentInstances: GameInstance[]
}>()
const theme = useTheming()
const appSettings = useAppSettings()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const loading = ref(true)
@@ -54,6 +61,100 @@ const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []
const MAX_JUMP_BACK_IN = 5
const MAX_NEW_INSTANCES = 3
const MAX_LINUX_POPULATES = 3
const ITEM_DRAG_DISTANCE = 92
const STORAGE_KEY = 'modrinth-jump-back-in-count'
const storedVisibleCount = Number.parseInt(localStorage.getItem(STORAGE_KEY) ?? '', 10)
const visibleItemLimit = ref(
Number.isFinite(storedVisibleCount)
? Math.max(1, Math.min(storedVisibleCount, MAX_JUMP_BACK_IN))
: MAX_JUMP_BACK_IN,
)
const maxVisibleItems = computed(() => Math.min(jumpBackInItems.value.length, MAX_JUMP_BACK_IN))
const visibleItemCount = computed(() => Math.min(visibleItemLimit.value, maxVisibleItems.value))
const visibleJumpBackInItems = computed(() =>
jumpBackInItems.value.slice(0, visibleItemCount.value),
)
const canResize = computed(() => maxVisibleItems.value > 1)
const resizing = ref(false)
const showOverdrag = ref(false)
function setVisibleItemLimit(count: number) {
const clamped = Math.max(1, Math.min(count, maxVisibleItems.value))
if (clamped >= maxVisibleItems.value) {
visibleItemLimit.value = MAX_JUMP_BACK_IN
localStorage.removeItem(STORAGE_KEY)
} else {
visibleItemLimit.value = clamped
localStorage.setItem(STORAGE_KEY, String(clamped))
}
}
function adjustVisibleItemLimit(delta: number) {
const target = visibleItemCount.value + delta
if (target < 1 || target > maxVisibleItems.value) {
flashOverdrag()
}
setVisibleItemLimit(target)
}
let dragStartY = 0
let dragStartCount = 0
let wasOverdragging = false
let overdragTimeout: ReturnType<typeof setTimeout> | undefined
function clearOverdragFlash() {
showOverdrag.value = false
if (overdragTimeout) {
clearTimeout(overdragTimeout)
overdragTimeout = undefined
}
}
function flashOverdrag() {
showOverdrag.value = true
if (overdragTimeout) clearTimeout(overdragTimeout)
overdragTimeout = setTimeout(() => {
showOverdrag.value = false
overdragTimeout = undefined
}, 500)
}
function onResizePointerDown(event: PointerEvent) {
if (!canResize.value) return
event.preventDefault()
resizing.value = true
wasOverdragging = false
clearOverdragFlash()
dragStartY = event.clientY
dragStartCount = visibleItemCount.value
document.body.classList.add('recent-worlds-resizing')
const handle = event.currentTarget as HTMLElement
handle.setPointerCapture(event.pointerId)
}
function onResizePointerMove(event: PointerEvent) {
if (!resizing.value) return
const target = dragStartCount + Math.round((event.clientY - dragStartY) / ITEM_DRAG_DISTANCE)
const isOverdragging = target < 1 || target > maxVisibleItems.value
if (isOverdragging && !wasOverdragging) {
flashOverdrag()
}
wasOverdragging = isOverdragging
setVisibleItemLimit(target)
}
function endResize(event?: PointerEvent) {
if (!resizing.value) return
resizing.value = false
wasOverdragging = false
clearOverdragFlash()
document.body.classList.remove('recent-worlds-resizing')
const target = event?.currentTarget as HTMLElement | undefined
if (event && target?.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId)
}
}
// Track populate calls on Linux to prevent server ping spam
const isLinux = platform() === 'linux'
@@ -76,7 +177,7 @@ type WorldJumpBackInItem = BaseJumpBackInItem & {
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home'))
const showWorlds = computed(() => appSettings.getFeatureFlag('worlds_in_home'))
watch([() => props.recentInstances, () => showWorlds.value], async () => {
await populateJumpBackIn().catch(() => {
@@ -276,100 +377,179 @@ onMounted(() => {
checkProcesses()
linuxPopulateCount.value = 0
})
onUnmounted(() => {
document.body.classList.remove('recent-worlds-resizing')
clearOverdragFlash()
})
</script>
<template>
<div v-if="loading" class="flex flex-col gap-2">
<span
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
>
{{ formatMessage(messages.jumpIn) }}
</span>
<div class="text-center py-4">
<Accordion
v-if="loading || jumpBackInItems.length > 0"
open-by-default
button-class="group mt-1 mb-3 flex w-fit cursor-pointer items-center border-0 bg-transparent p-0 text-left"
>
<template #title>
<span class="flex items-center gap-1 text-2xl font-semibold leading-none text-contrast mr-1">
{{ formatMessage(messages.jumpIn) }}
</span>
</template>
<div v-if="loading" class="text-center py-4">
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
</div>
</div>
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<span
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
>
{{ formatMessage(messages.jumpIn) }}
</span>
<div class="grid-when-huge flex flex-col w-full gap-3">
<template
v-for="item in jumpBackInItems"
:key="`${item.instance.id}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
>
<WorldItem
v-if="item.type === 'world'"
:world="item.world"
:playing-instance="runningInstances.includes(item.instance.id)"
:playing-world="
currentInstance === item.instance.id && currentWorld === getWorldIdentifier(item.world)
"
:refreshing="
item.world.type === 'server'
? serverData[item.world.address].refreshing && !serverData[item.world.address].status
: undefined
"
:supports-server-quick-play="
item.world.type === 'server' &&
hasServerQuickPlaySupport(gameVersions, item.instance.game_version || '')
"
:supports-world-quick-play="
item.world.type === 'singleplayer' &&
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version || '')
"
:quarantined="item.instance.quarantined"
:server-status="
item.world.type === 'server' ? serverData[item.world.address].status : undefined
"
:rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
"
:current-protocol="protocolVersions[item.instance.id]"
:game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
"
:instance-id="item.instance.id"
:instance-name="item.instance.name"
:instance-icon="item.instance.icon_path"
@refresh="
() =>
<div v-else class="grid-when-huge relative flex w-full flex-col gap-3">
<TransitionGroup name="jump-back-in-item">
<div
v-for="item in visibleJumpBackInItems"
:key="`${item.instance.id}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
class="jump-back-in-item min-w-0"
>
<WorldItem
v-if="item.type === 'world'"
:world="item.world"
:playing-instance="runningInstances.includes(item.instance.id)"
:playing-world="
currentInstance === item.instance.id &&
currentWorld === getWorldIdentifier(item.world)
"
:refreshing="
item.world.type === 'server'
? refreshServer(item.world.address, item.instance.id)
: {}
"
@update="() => populateJumpBackIn()"
@play="
() => {
currentInstance = item.instance.id
currentWorld = getWorldIdentifier(item.world)
joinWorld(item.world, item.instance)
}
"
@play-instance="
() => {
currentInstance = item.instance.id
playInstance(item.instance)
}
"
@stop="() => stopInstance(item.instance.id)"
/>
<InstanceItem
v-else
:instance="item.instance"
:last_played="item.sort_time"
:newly-added="item.newly_added"
@play="() => markInstancePlayed(item)"
/>
</template>
? serverData[item.world.address].refreshing &&
!serverData[item.world.address].status
: undefined
"
:supports-server-quick-play="
item.world.type === 'server' &&
hasServerQuickPlaySupport(gameVersions, item.instance.game_version || '')
"
:supports-world-quick-play="
item.world.type === 'singleplayer' &&
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version || '')
"
:quarantined="item.instance.quarantined"
:server-status="
item.world.type === 'server' ? serverData[item.world.address].status : undefined
"
:rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
"
:current-protocol="protocolVersions[item.instance.id]"
:game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
"
:instance-id="item.instance.id"
:instance-name="item.instance.name"
:instance-icon="item.instance.icon_path"
@refresh="
() =>
item.world.type === 'server'
? refreshServer(item.world.address, item.instance.id)
: {}
"
@update="() => populateJumpBackIn()"
@play="
() => {
currentInstance = item.instance.id
currentWorld = getWorldIdentifier(item.world)
joinWorld(item.world, item.instance)
}
"
@play-instance="
() => {
currentInstance = item.instance.id
playInstance(item.instance)
}
"
@stop="() => stopInstance(item.instance.id)"
/>
<InstanceItem
v-else
:instance="item.instance"
:last_played="item.sort_time"
:newly-added="item.newly_added"
@play="() => markInstancePlayed(item)"
/>
</div>
</TransitionGroup>
<div
v-if="canResize"
v-tooltip="resizing ? null : formatMessage(messages.resize)"
role="separator"
tabindex="0"
aria-orientation="horizontal"
:aria-label="formatMessage(messages.resize)"
:aria-valuemin="1"
:aria-valuemax="maxVisibleItems"
:aria-valuenow="visibleItemCount"
class="group/resize relative inset-x-0 bottom-0 flex h-6 -mt-3 w-1/3 mx-auto cursor-ns-resize touch-none select-none items-center justify-center"
@pointerdown="onResizePointerDown"
@pointermove="onResizePointerMove"
@pointerup="endResize"
@pointercancel="endResize"
@keydown.up.prevent="adjustVisibleItemLimit(-1)"
@keydown.down.prevent="adjustVisibleItemLimit(1)"
@keydown.home.prevent="setVisibleItemLimit(1)"
@keydown.end.prevent="setVisibleItemLimit(maxVisibleItems)"
>
<div class="flex flex-col gap-0.5">
<span
class="h-px w-6 transition-colors"
:class="showOverdrag ? 'bg-red' : 'bg-secondary'"
/>
<span
class="h-px w-6 transition-colors"
:class="showOverdrag ? 'bg-red' : 'bg-secondary'"
/>
</div>
</div>
</div>
</div>
</Accordion>
</template>
<style scoped lang="scss">
.grid-when-huge {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
}
.jump-back-in-item {
height: 5rem;
overflow: hidden;
}
.jump-back-in-item-enter-active,
.jump-back-in-item-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease,
height 0.25s ease;
}
.jump-back-in-item-enter-from,
.jump-back-in-item-leave-to {
opacity: 0;
transform: scale(0.98);
height: 0;
}
@media (prefers-reduced-motion: reduce) {
.jump-back-in-item-enter-active,
.jump-back-in-item-leave-active {
transition: none;
}
.jump-back-in-item-enter-from,
.jump-back-in-item-leave-to {
opacity: 1;
transform: none;
height: 5rem;
}
}
</style>
<style lang="scss">
body.recent-worlds-resizing,
body.recent-worlds-resizing * {
cursor: ns-resize !important;
}
</style>
@@ -9,6 +9,7 @@ import {
MoreVerticalIcon,
NoSignalIcon,
PlayIcon,
SignalIcon,
SkullIcon,
SpinnerIcon,
StopCircleIcon,
@@ -25,15 +26,17 @@ import {
commonMessages,
defineMessages,
injectNotificationManager,
ServerOnlinePlayers,
SmartClickable,
TagItem,
TeleportOverflowMenu,
useFormatDateTime,
useFormatNumber,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { getPingLevel } from '@modrinth/utils'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -53,6 +56,7 @@ import { LockIcon } from '../../../../../../packages/assets/generated-icons'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatNumber = useFormatNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
@@ -121,6 +125,9 @@ const props = withDefaults(
)
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
const hasPlayersTooltip = computed(
() => !!props.serverStatus?.players?.sample && props.serverStatus.players.sample.length > 0,
)
const serverIncompatible = computed(
() =>
!!props.serverStatus &&
@@ -238,6 +245,10 @@ const messages = defineMessages({
id: 'app.world.world-item.incompatible-version',
defaultMessage: 'Incompatible version {version}',
},
playersOnline: {
id: 'app.world.world-item.players-online',
defaultMessage: '{count} online',
},
offline: {
id: 'app.world.world-item.offline',
defaultMessage: 'Offline',
@@ -315,13 +326,34 @@ const messages = defineMessages({
}}
</span>
</template>
<div v-else class="flex items-center gap-2">
<ServerOnlinePlayers
:online="serverStatus.players?.online ?? 0"
status-online
hide-label
<template v-else>
<SignalIcon
v-tooltip="`${serverStatus.ping}ms`"
aria-hidden="true"
:style="`--_signal-${getPingLevel(serverStatus.ping ?? 0)}: var(--color-green)`"
stroke-width="3px"
class="shrink-0 smart-clickable:allow-pointer-events"
/>
</div>
<Tooltip :disabled="!hasPlayersTooltip">
<span
class="smart-clickable:allow-pointer-events"
:class="{ 'cursor-help': hasPlayersTooltip }"
>
{{
formatMessage(messages.playersOnline, {
count: formatNumber(serverStatus.players?.online ?? 0),
})
}}
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.id">
{{ player.name }}
</span>
</div>
</template>
</Tooltip>
</template>
</template>
<template v-else>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
@@ -10,6 +10,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import type { Router } from 'vue-router'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import {
install_job_dismiss,
install_job_list,
@@ -23,7 +24,6 @@ import {
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
import { injectAppEvents } from '@/providers/app-events'
import { useTheming } from '@/store/state'
const messages = defineMessages({
installs: {
@@ -236,7 +236,7 @@ export async function useInstallJobNotifications(opts: {
}) {
const appEvents = injectAppEvents()
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const appSettings = useAppSettings()
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
@@ -421,7 +421,7 @@ export async function useInstallJobNotifications(opts: {
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isTerminalJob(job) || themeStore.getFeatureFlag('always_show_copy_details')
return isTerminalJob(job) || appSettings.getFeatureFlag('always_show_copy_details')
}
function isCopied(job: InstallJobSnapshot): boolean {
@@ -0,0 +1,53 @@
import { reactive, ref } from 'vue'
export const DEFAULT_FEATURE_FLAGS = {
project_background: false,
page_path: false,
worlds_in_home: true,
server_project_qa: false,
show_version_environment_column: false,
server_ram_as_bytes_always_on: false,
always_show_app_controls: false,
skip_non_essential_warnings: false,
skip_unknown_pack_warning: false,
pride_fundraiser: true,
i18n_debug: false,
show_instance_play_time: true,
compact_instance_cards: false,
advanced_filters_collapsed: true,
always_show_copy_details: false,
hide_installed_modpacks: false,
friends_active_collapsed: false,
friends_online_collapsed: false,
friends_offline_collapsed: true,
friends_pending_collapsed: true,
dismissed_photosensitivity_filter_warning: false,
}
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
type FeatureFlags = Record<FeatureFlag, boolean>
const syncBehaviorAcrossDevices = ref(false)
const featureFlags = reactive<FeatureFlags>({ ...DEFAULT_FEATURE_FLAGS })
function setBehaviorSyncAcrossDevices(enabled: boolean): void {
syncBehaviorAcrossDevices.value = enabled
}
function getFeatureFlag(key: FeatureFlag): boolean {
return featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
}
const appSettings = reactive({
syncBehaviorAcrossDevices,
hideNametagSkinsPage: false,
toggleSidebar: false,
devMode: false,
featureFlags,
setBehaviorSyncAcrossDevices,
getFeatureFlag,
})
export function useAppSettings() {
return appSettings
}
@@ -0,0 +1,48 @@
import { reactive } from 'vue'
import { findMinecraftAuthError } from '@/components/ui/minecraft-auth-error-modal/minecraft-auth-errors'
const errorState = reactive({
errorModal: null,
minecraftAuthErrorModal: null,
minecraftRequiredModal: null,
setErrorModal(ref) {
this.errorModal = ref
},
setMinecraftAuthErrorModal(ref) {
this.minecraftAuthErrorModal = ref
},
setMinecraftRequiredModal(ref) {
this.minecraftRequiredModal = ref
},
showError(error, context, closable = true, source = null) {
const errorMessage = error.message?.toLowerCase()
if (
(errorMessage?.includes('user is not logged in') ||
errorMessage?.includes('cannot play instance since minecraft is required')) &&
this.minecraftRequiredModal
) {
this.minecraftRequiredModal.show()
return
}
if (
error.message &&
(error.message.includes('Minecraft authentication error:') ||
findMinecraftAuthError(error.message)) &&
this.minecraftAuthErrorModal
) {
this.minecraftAuthErrorModal.show(error)
return
}
this.errorModal.show(error, context, closable, source)
},
})
export function useError() {
return errorState
}
export const handleSevereError = (error, context) => {
useError().showError(error, context)
console.error(error)
}
@@ -0,0 +1,47 @@
import { computed, reactive, ref, watch } from 'vue'
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'retro', 'system'] as const
export type ColorTheme = (typeof THEME_OPTIONS)[number]
type Theme = Exclude<ColorTheme, 'system'>
const preferred = ref<ColorTheme>('dark')
const preview = ref<ColorTheme | null>(null)
const advancedRendering = ref(true)
const syncAcrossDevices = ref(false)
const nativeThemeQuery = window.matchMedia('(prefers-color-scheme: dark)')
const native = ref<Theme>(nativeThemeQuery.matches ? 'dark' : 'light')
const active = computed<Theme>(() => {
const selectedTheme = preview.value ?? preferred.value
return selectedTheme === 'system' ? native.value : selectedTheme
})
nativeThemeQuery.addEventListener('change', (event) => {
native.value = event.matches ? 'dark' : 'light'
})
watch(
active,
(theme) => {
const html = document.documentElement
for (const option of THEME_OPTIONS) {
html.classList.remove(`${option}-mode`)
}
html.classList.add(`${theme}-mode`)
},
{ immediate: true },
)
const theme = reactive({
preferred,
preview,
active,
native,
syncAcrossDevices,
advancedRendering,
options: THEME_OPTIONS,
})
export function useTheme() {
return theme
}
+4 -1
View File
@@ -5,8 +5,9 @@
*/
import { invoke } from '@tauri-apps/api/core'
import type { FeatureFlag } from '@/composables/use-app-settings.ts'
import type { ColorTheme } from '@/composables/use-theme.ts'
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
// Settings object
/*
@@ -43,6 +44,8 @@ export type AppSettings = {
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean
sync_theme_across_devices: boolean
sync_behavior_across_devices: boolean
telemetry: boolean
discord_rpc: boolean
+2
View File
@@ -213,6 +213,8 @@ type AppSettings = {
advanced_rendering: boolean
native_decorations: boolean
worlds_in_home: boolean
sync_theme_across_devices: boolean
sync_behavior_across_devices: boolean
telemetry: boolean
discord_rpc: boolean
@@ -0,0 +1,19 @@
import type { UserPreferencesContext } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
type UserPreferences = NonNullable<UserPreferencesContext['preferences']['value']>
type PartialUserPreferences = Parameters<UserPreferencesContext['updatePreferences']>[0]
export async function get_user_preferences(userId: string): Promise<UserPreferences> {
return await invoke<UserPreferences>('plugin:users|get_user_preferences', { userId })
}
export async function patch_user_preferences(
userId: string,
preferences: PartialUserPreferences,
): Promise<UserPreferences> {
return await invoke<UserPreferences>('plugin:users|patch_user_preferences', {
userId,
preferences,
})
}
+2 -2
View File
@@ -25,8 +25,8 @@ export async function get_user_profile(userId: string): Promise<Labrinth.Users.v
return await invoke<Labrinth.Users.v3.User>('plugin:users|get_user_profile', { userId })
}
export async function get_user_projects(userId: string): Promise<Labrinth.Projects.v2.Project[]> {
return await invoke<Labrinth.Projects.v2.Project[]>('plugin:users|get_user_projects', {
export async function get_user_projects(userId: string): Promise<Labrinth.Projects.v3.Project[]> {
return await invoke<Labrinth.Projects.v3.Project[]>('plugin:users|get_user_projects', {
userId,
})
}
@@ -134,12 +134,6 @@
"app.ads-consent.reject": {
"message": "رفض الكل"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "العرض المتقدم"
},
"app.appearance-settings.color-theme.title": {
"message": "لون السمة"
},
"app.appearance-settings.default-landing-page.home": {
"message": "صفحة الرئيسية"
},
@@ -161,12 +155,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "تصغير نافذة المشغل"
},
"app.appearance-settings.native-decorations.description": {
"message": "استخدام إطار نافذة النظام (يتطلب إعادة تشغيل التطبيق)."
},
"app.appearance-settings.native-decorations.title": {
"message": "الزخارف التقليدية"
},
"app.appearance-settings.show-play-time.description": {
"message": "يعرض مقدار الوقت الذي قضيته في لعب إحدى المهام."
},
@@ -74,12 +74,6 @@
"app.ads-consent.title": {
"message": "Vaše soukromí a způsob, jakým reklamy podporují Modrinth"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Pokročilé vykreslování"
},
"app.appearance-settings.color-theme.title": {
"message": "Barevný motiv"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Domů"
},
@@ -101,12 +95,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimalizovat launcher"
},
"app.appearance-settings.native-decorations.description": {
"message": "Použít systémový rámeček okna (vyžaduje restart aplikace)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Systémové dekorace"
},
"app.appearance-settings.show-play-time.description": {
"message": "Zobrazuje, kolik času jsi strávil hraním instance."
},
@@ -137,12 +137,6 @@
"app.ads-consent.title": {
"message": "Dit privatliv og hvordan reklamer hjælper Modrinth"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Avanceret rendering"
},
"app.appearance-settings.color-theme.title": {
"message": "Farvetema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Hjem"
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Deine Privatsphäre und wie Werbung Modrinth unterstützt"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Visuelle Effekte wie Hintergrundunschärfe aktivieren. Ohne Hardwarebeschleunigung kann dies die Leistung beeinträchtigen."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Erweitertes Rendering"
},
"app.appearance-settings.color-theme.description": {
"message": "Wähle das Farbschema der Modrinth App aus."
},
"app.appearance-settings.color-theme.title": {
"message": "Farbschema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Start"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "App minimieren"
},
"app.appearance-settings.native-decorations.description": {
"message": "Nutzt die Titelleiste und Fenstersteuerung deines Betriebssystems. Erfordert einen App-Neustart."
},
"app.appearance-settings.native-decorations.title": {
"message": "System-Fensterrahmen"
},
"app.appearance-settings.show-play-time.description": {
"message": "Zeigt, wie lange du jede einzelne Instanz gespielt hast."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Deine Privatsphäre und wie Werbung Modrinth unterstützt"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Visuelle Effekte wie Hintergrundunschärfe aktivieren. Ohne Hardwarebeschleunigung kann dies die Leistung beeinträchtigen."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Erweitertes Rendering"
},
"app.appearance-settings.color-theme.description": {
"message": "Wähle das Farbschema der Modrinth App aus."
},
"app.appearance-settings.color-theme.title": {
"message": "Farbschema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Start"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "App minimieren"
},
"app.appearance-settings.native-decorations.description": {
"message": "Nutzt die Titelleiste und Fenstersteuerung deines Betriebssystems. Erfordert einen App-Neustart."
},
"app.appearance-settings.native-decorations.title": {
"message": "System-Fensterrahmen"
},
"app.appearance-settings.show-play-time.description": {
"message": "Zeigt, wie lange du jede einzelne Instanz gespielt hast."
},
+37 -16
View File
@@ -149,17 +149,11 @@
"app.ads-consent.title": {
"message": "Your privacy and how ads support Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Enable visual effects such as background blur. This may reduce performance without hardware acceleration."
"app.appearance-settings.compact-mode.description": {
"message": "Display library instances in a compact row layout."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Advanced rendering"
},
"app.appearance-settings.color-theme.description": {
"message": "Choose the color theme used by Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Color theme"
"app.appearance-settings.compact-mode.title": {
"message": "Compact mode"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Home"
@@ -185,12 +179,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimize app"
},
"app.appearance-settings.native-decorations.description": {
"message": "Use your operating system's title bar and window controls. Requires an app restart."
},
"app.appearance-settings.native-decorations.title": {
"message": "System window frame"
},
"app.appearance-settings.show-play-time.description": {
"message": "Show how long you've played each instance."
},
@@ -230,6 +218,15 @@
"app.behavior-settings.startup-and-navigation.title": {
"message": "Startup and navigation"
},
"app.behavior-settings.sync-across-devices.description": {
"message": "Use these behavior settings everywhere you're signed in. Turn this off to keep separate settings on this device."
},
"app.behavior-settings.sync-across-devices.signed-out-tooltip": {
"message": "Sign into a Modrinth account to sync settings."
},
"app.behavior-settings.sync-across-devices.title": {
"message": "Sync behavior across devices"
},
"app.browse.add-servers-to-instance": {
"message": "Adding server to instance"
},
@@ -332,6 +329,9 @@
"app.home.jump-back-in.new-instance": {
"message": "New instance"
},
"app.home.jump-back-in.resize": {
"message": "Drag to resize"
},
"app.home.jump-back-in.title": {
"message": "Jump in"
},
@@ -1082,6 +1082,9 @@
"app.library.sort.date-modified": {
"message": "Date modified"
},
"app.library.sort.game-version": {
"message": "Game version"
},
"app.library.sort.hours-played": {
"message": "Hours played"
},
@@ -1091,6 +1094,9 @@
"app.library.sort.last-played": {
"message": "Last played"
},
"app.library.sort.loader": {
"message": "Loader"
},
"app.library.sort.name": {
"message": "Name"
},
@@ -1742,6 +1748,9 @@
"app.world.world-item.offline": {
"message": "Offline"
},
"app.world.world-item.players-online": {
"message": "{count} online"
},
"content.shared-instance.change-version-body": {
"message": "Changing the version only changes your local copy. Future shared instance updates may restore or change it again."
},
@@ -2069,6 +2078,12 @@
"instance.icon-editor.symbol.engine": {
"message": "Engine"
},
"instance.icon-editor.symbol.fabric": {
"message": "Fabric"
},
"instance.icon-editor.symbol.forge": {
"message": "Forge"
},
"instance.icon-editor.symbol.furnace": {
"message": "Furnace"
},
@@ -2090,6 +2105,9 @@
"instance.icon-editor.symbol.mr-pack": {
"message": "Mr Pack"
},
"instance.icon-editor.symbol.neoforge": {
"message": "NeoForge"
},
"instance.icon-editor.symbol.orb": {
"message": "Orb"
},
@@ -2105,6 +2123,9 @@
"instance.icon-editor.symbol.poke-ball": {
"message": "Poke Ball"
},
"instance.icon-editor.symbol.quilt": {
"message": "Quilt"
},
"instance.icon-editor.symbol.redstone-block": {
"message": "Redstone Block"
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Tu privacidad y como los anuncios apoyan a Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Activar efectos visuales como los efectos de desenfoque. Esto puede reducir el rendimiento si no tienes aceleración de hardware."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Renderizado avanzado"
},
"app.appearance-settings.color-theme.description": {
"message": "Escoge el esquema de colores usado por la aplicación de Modrinth."
},
"app.appearance-settings.color-theme.title": {
"message": "Color de la interfaz"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Inicio"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar app"
},
"app.appearance-settings.native-decorations.description": {
"message": "Usa el borde de ventana de tu sistema. Necesita reiniciar la aplicación."
},
"app.appearance-settings.native-decorations.title": {
"message": "Decoraciones nativas"
},
"app.appearance-settings.show-play-time.description": {
"message": "Muestra cuanto tiempo has jugado en cada instancia."
},
@@ -95,12 +95,6 @@
"app.action-bar.view-logs": {
"message": "Ver registros"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Renderizado avanzado"
},
"app.appearance-settings.color-theme.title": {
"message": "Tema de color"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Inicio"
},
@@ -122,12 +116,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar lanzador"
},
"app.appearance-settings.native-decorations.description": {
"message": "Usa el marco de ventana del sistema (requiere reiniciar la aplicación)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Decoraciones nativas"
},
"app.appearance-settings.show-play-time.description": {
"message": "Muestra cuánto tiempo has estado jugando en una instancia."
},
@@ -71,12 +71,6 @@
"app.action-bar.view-logs": {
"message": "Katso lokeja"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Edistynyt renderointi"
},
"app.appearance-settings.color-theme.title": {
"message": "Väriteema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Koti"
},
@@ -98,12 +92,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Piilota käynnistysohjelma"
},
"app.appearance-settings.native-decorations.description": {
"message": "Käytä järjestelmän ikkuna kehystä (Vaatii sovelluksen uudelleen käynnistämisen)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Natiivit koristukset"
},
"app.appearance-settings.show-play-time.description": {
"message": "Näyttää, kuinka paljon aikaa olet käyttänyt instanssin pelaamiseen."
},
@@ -47,12 +47,6 @@
"app.action-bar.view-logs": {
"message": "Tignan ang logs"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Masalimuot na pagrerender"
},
"app.appearance-settings.color-theme.title": {
"message": "Tema ng kulay"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Tahanan"
},
@@ -74,12 +68,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Liitin ang launcher"
},
"app.appearance-settings.native-decorations.description": {
"message": "Gamitin ang system window frame (kailangan i-restart ang app)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Native decorations"
},
"app.appearance-settings.show-play-time.description": {
"message": "Magpapakita ang katagal ng iyong paglalaro sa isang instansiya."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Votre vie privée et comment les publicités soutiennent Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Activer les effets visuels tel que le flou d'arrière-plan. Ceci peut réduire les performances sans accélération matérielle."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Rendu avancé"
},
"app.appearance-settings.color-theme.description": {
"message": "Choisir la couleur de thème utilisée par Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Couleur du thème"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Accueil"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimiser l'application"
},
"app.appearance-settings.native-decorations.description": {
"message": "Utilise la barre de titre et les contrôles de fenêtre de votre système d'exploitation. Nécessite le redémarrage de l'application."
},
"app.appearance-settings.native-decorations.title": {
"message": "Cadre de fenêtre système"
},
"app.appearance-settings.show-play-time.description": {
"message": "Montre combien de temps vous avez joué à chaque instance."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Adataid védelme és a hirdetések szerepe a Modrinth működésében"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Engedélyezi a vizuális effektusokat, például a háttér elmosását. Hardveres gyorsítás nélkül ez ronthatja a rendszer teljesítményét."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Haladó megjelenítési beállítások"
},
"app.appearance-settings.color-theme.description": {
"message": "Válaszd ki a Modrinth App által használt színsémát."
},
"app.appearance-settings.color-theme.title": {
"message": "Téma"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Kezdőlap"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Alkalmazás bezárása"
},
"app.appearance-settings.native-decorations.description": {
"message": "Az operációs rendszer címsorának és ablakvezérlőinek használata. A módosítás érvénybe lépéséhez újra kell indítani az alkalmazást."
},
"app.appearance-settings.native-decorations.title": {
"message": "Rendszerablakkeret"
},
"app.appearance-settings.show-play-time.description": {
"message": "Megjeleníti, hogy mennyi időt töltöttél egy példányban."
},
@@ -44,12 +44,6 @@
"app.action-bar.view-logs": {
"message": "Lihat catatan"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Renderasi tingkat lanjut"
},
"app.appearance-settings.color-theme.title": {
"message": "Tema warna"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Beranda"
},
@@ -71,12 +65,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Kecilkan peluncur"
},
"app.appearance-settings.native-decorations.description": {
"message": "Gunakan bingkai jendela sistem (wajib menjalankan ulang aplikasi)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Hiasan asli"
},
"app.appearance-settings.show-play-time.description": {
"message": "Menampilkan seberapa lama Anda memainkan sebuah instans."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "La tua privacy e come le pubblicità supportano Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Abilita effetti come lo sfondo sfocato. Questo potrebbe ridurre le prestazioni senza accelerazione hardware."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Rendering avanzato"
},
"app.appearance-settings.color-theme.description": {
"message": "Scegli il tema usato da Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Tema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Home"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizza app"
},
"app.appearance-settings.native-decorations.description": {
"message": "Usa la barra e i controlli delle finestre di sistema. Richiede un riavvio."
},
"app.appearance-settings.native-decorations.title": {
"message": "Usa cornice di sistema"
},
"app.appearance-settings.show-play-time.description": {
"message": "Mostra quanto tempo hai giocato a un'istanza."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "プライバシーと広告がModrinthを支える仕組み"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "背景のぼかしなどの視覚効果を有効にします。ハードウェアアクセラレーションが無効の場合、パフォーマンスが低下する可能性があります。"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "高度なレンダリング"
},
"app.appearance-settings.color-theme.description": {
"message": "Modrinth Appのテーマを選びましょう。"
},
"app.appearance-settings.color-theme.title": {
"message": "テーマ"
},
"app.appearance-settings.default-landing-page.home": {
"message": "ホーム"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "ランチャーを最小化"
},
"app.appearance-settings.native-decorations.description": {
"message": "OS標準のタイトルバーとウィンドウ操作ボタンを使用します。反映にはアプリの再起動が必要です。"
},
"app.appearance-settings.native-decorations.title": {
"message": "システムウィンドウフレーム"
},
"app.appearance-settings.show-play-time.description": {
"message": "各インスタンスのプレイ時間を表示します。"
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "개인정보와 광고가 Modrinth를 지원하는 방식"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "배경 흐림 효과와 같은 시각 효과를 활성화합니다. 하드웨어 가속이 지원되지 않는 경우 성능이 저하될 수 있습니다."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "고급 렌더링"
},
"app.appearance-settings.color-theme.description": {
"message": "Modrinth 앱에서 사용할 색상 테마를 선택하세요."
},
"app.appearance-settings.color-theme.title": {
"message": "색상 테마"
},
"app.appearance-settings.default-landing-page.home": {
"message": "홈"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "앱 최소화"
},
"app.appearance-settings.native-decorations.description": {
"message": "운영 체제의 제목 표시줄 및 창 제어 기능을 사용합니다. 앱 재시작이 필요합니다."
},
"app.appearance-settings.native-decorations.title": {
"message": "시스템 창 프레임"
},
"app.appearance-settings.show-play-time.description": {
"message": "각 인스턴스를 얼마나 플레이했는지 표시합니다."
},
@@ -53,12 +53,6 @@
"app.action-bar.view-logs": {
"message": "Lihat log"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Pemaparan lanjutan"
},
"app.appearance-settings.color-theme.title": {
"message": "Tema warna"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Laman utama"
},
@@ -80,12 +74,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimumkan pelancar"
},
"app.appearance-settings.native-decorations.description": {
"message": "Gunakan bingkai tetingkap sistem (aplikasi perlu dimulakan semula)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Hiasan asli"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Melangkau pengesahan berisiko rendah secara automatik seperti pemasangan pek mod pendua, pemadaman kandungan biasa, kemas kini pukal, menyahpautkan pek mod dan gesaan pembaikan. Amaran berbahaya masih akan ditunjukkan."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Je privacy en hoe advertenties Modrinth ondersteunen"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Schakel visuele effecten zoals achtergrondvervaging in. Zonder hardwareversnelling kan dit ten koste gaan van de prestaties."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Geavanceerde weergave"
},
"app.appearance-settings.color-theme.description": {
"message": "Kies het kleurenthema dat door de Modrinth-app wordt gebruikt."
},
"app.appearance-settings.color-theme.title": {
"message": "Kleurenthema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Start"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "App minimaliseren"
},
"app.appearance-settings.native-decorations.description": {
"message": "Gebruik de titelbalk en vensterbedieningselementen van je besturingssysteem. Hiervoor moet de app opnieuw worden opgestart."
},
"app.appearance-settings.native-decorations.title": {
"message": "Systeemvensterframe"
},
"app.appearance-settings.show-play-time.description": {
"message": "Laat zien hoe lang je elke instantie hebt gespeeld."
},
@@ -56,12 +56,6 @@
"app.action-bar.view-logs": {
"message": "Vis logger"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Avansert rendering"
},
"app.appearance-settings.color-theme.title": {
"message": "Fargetema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Hjem"
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Twoja prywatność i jak reklamy wspierają Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Włącz efekty wizualne, takie jak rozmycie tła. Może to negatywnie wpłynąć na wydajność, jeżeli akceleracja sprzętowa jest wyłączona."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Zaawansowane renderowanie"
},
"app.appearance-settings.color-theme.description": {
"message": "Wybierz motyw użyty przez Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Motyw"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Strona główna"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimalizuj aplikację"
},
"app.appearance-settings.native-decorations.description": {
"message": "Używaj pasek tytułu i kontrolki okna systemu operacyjnego. Wymaga ponownego uruchomienia aplikacji."
},
"app.appearance-settings.native-decorations.title": {
"message": "Systemowa ramka okna"
},
"app.appearance-settings.show-play-time.description": {
"message": "Pokazuje czas gry spędzony w danej instancji."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Sua privacidade e como anúncios apoiam o Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Ativa os efeitos visuais como o desfoque de fundo. Isso talvez reduza o desempenho sem o aceleramento de hardware."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Renderização avançada"
},
"app.appearance-settings.color-theme.description": {
"message": "Escolha o tema usado pelo Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Tema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Início"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar aplicativo"
},
"app.appearance-settings.native-decorations.description": {
"message": "Usa a barra de título e os controles de janela do seu sistema operacional. Exige a reinicialização do aplicativo."
},
"app.appearance-settings.native-decorations.title": {
"message": "Moldura de janela do sistema"
},
"app.appearance-settings.show-play-time.description": {
"message": "Exibe o tempo de jogo de cada instância."
},
@@ -140,12 +140,6 @@
"app.ads-consent.title": {
"message": "A sua privacidade e como os anúncios são financiados pela Modrinth"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Renderização avançada"
},
"app.appearance-settings.color-theme.title": {
"message": "Cor de tema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Casa"
},
@@ -167,12 +161,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimizar launcher"
},
"app.appearance-settings.native-decorations.description": {
"message": "Utilizar a moldura da janela do sistema (é necessário reiniciar a aplicação)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Decorações nativas"
},
"app.appearance-settings.show-play-time.description": {
"message": "Apresenta quanto tempo passou a jogar uma instância."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Конфиденциальность и реклама на Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Использовать эффекты вроде размытия фона. Без аппаратного ускорения возможна потеря производительности."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Расширенные эффекты"
},
"app.appearance-settings.color-theme.description": {
"message": "Выберите тему для Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Тема"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Главная"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Сворачивание приложения"
},
"app.appearance-settings.native-decorations.description": {
"message": "Использовать системное оформление заголовка и кнопок окна. Требуется перезапуск."
},
"app.appearance-settings.native-decorations.title": {
"message": "Системная рамка окна"
},
"app.appearance-settings.show-play-time.description": {
"message": "Показывать проведённое время в сборке."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Tvoja bezbednost i kako reklame pomažu Modrinth-u"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Uključi vizuelne efekte kao što je blur pozadine. Ovo može da pogorša performanse bez hardverskog ubrzanja."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Napredno renderovanje"
},
"app.appearance-settings.color-theme.description": {
"message": "Izaberi boju teme koju koristi Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Tema boje"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Početna stranica"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Smanji aplikaciju"
},
"app.appearance-settings.native-decorations.description": {
"message": "Koristi naslovnu traku i kontrole prozora tvog operativnog sistema. Restart aplikacije obavezan."
},
"app.appearance-settings.native-decorations.title": {
"message": "Sistemski okvir prozora"
},
"app.appearance-settings.show-play-time.description": {
"message": "Prikaži koliko dugo ste igrali svaku instancu."
},
@@ -140,15 +140,6 @@
"app.ads-consent.title": {
"message": "Din integritet och hur reklam stödjer Modrinth"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Avancerad rendering"
},
"app.appearance-settings.color-theme.description": {
"message": "Välj färgtemat som används av Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Färgtema"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Hem"
},
@@ -170,12 +161,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimera app"
},
"app.appearance-settings.native-decorations.description": {
"message": "Använd systemets fönsterram (omstart av app krävs)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Inbyggda fönsterdekorationer"
},
"app.appearance-settings.show-play-time.description": {
"message": "Visa hur länge du spelat varje instans."
},
@@ -59,12 +59,6 @@
"app.action-bar.view-logs": {
"message": "ดู Log"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "การเรนเดอร์ขั้นสูง"
},
"app.appearance-settings.color-theme.title": {
"message": "ธีมสี"
},
"app.appearance-settings.default-landing-page.home": {
"message": "หน้าหลัก"
},
@@ -86,12 +80,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "ย่อลันเชอร์"
},
"app.appearance-settings.native-decorations.description": {
"message": "ใช้กรอบหน้าต่างระบบ (ต้องรีสตาร์ทแอป)"
},
"app.appearance-settings.native-decorations.title": {
"message": "ของตกแต่งดั้งเดิม"
},
"app.appearance-settings.show-play-time.description": {
"message": "แสดงเวลาที่คุณใช้เล่นในแต่ละอินสแตนซ์"
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Sizin mahremiyetiniz ve reklamlar Modrinth'i nasıl destekler"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Arka plan bulanıklığı gibi görsel efektleri etkinleştirin. Bu, donanım ivmelendirme yokken performansı etkileyebilir."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Gelişmiş işleme"
},
"app.appearance-settings.color-theme.description": {
"message": "Modrinth App tarafından kullanılacak renk şemasını seçin."
},
"app.appearance-settings.color-theme.title": {
"message": "Renk teması"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Ana Sayfa"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Uygulamayı küçült"
},
"app.appearance-settings.native-decorations.description": {
"message": "İşletim sisteminizin başlık çubuğu ve pencere kontrollerini kullanın. Uygulamanın yeniden başlatılmasını gerektirir."
},
"app.appearance-settings.native-decorations.title": {
"message": "Sistem pencere çerçevesi"
},
"app.appearance-settings.show-play-time.description": {
"message": "Her bir örneği ne kadar süreyle oynadığını göster."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "Ваша приватність та як реклама підтримує Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Умикає візуальні ефекти, як-от розмиття фону. Це може знизити продуктивність без апаратного прискорення."
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Розширене промальовування"
},
"app.appearance-settings.color-theme.description": {
"message": "Оберіть тему для Modrinth App."
},
"app.appearance-settings.color-theme.title": {
"message": "Колірна тема"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Головна"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Згортати застосунок"
},
"app.appearance-settings.native-decorations.description": {
"message": "Використовувати заголовок вікна та елементи керування вашої операційної системи. Потребує перезапуску застосунку."
},
"app.appearance-settings.native-decorations.title": {
"message": "Системна рамка вікна"
},
"app.appearance-settings.show-play-time.description": {
"message": "Показувати награний час для кожної збірки."
},
@@ -134,12 +134,6 @@
"app.ads-consent.title": {
"message": "Quyền riêng tư của bạn và cách quảng cáo hỗ trợ Modrinth"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "Dựng hình nâng cao"
},
"app.appearance-settings.color-theme.title": {
"message": "Màu chủ đề"
},
"app.appearance-settings.default-landing-page.home": {
"message": "Trang chủ"
},
@@ -161,12 +155,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Thu nhỏ launcher"
},
"app.appearance-settings.native-decorations.description": {
"message": "Sử dụng khung cửa sổ hệ thống (yêu cầu khởi động lại ứng dụng)."
},
"app.appearance-settings.native-decorations.title": {
"message": "Giao diện cửa sổ hệ thống"
},
"app.appearance-settings.show-play-time.description": {
"message": "Hiển thị thời gian bạn đã dành để chơi bằng một cấu hình."
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "您的隐私以及广告如何支持 Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "启用模糊效果等高级渲染。可能会在没有硬件加速渲染的情况下造成性能问题。"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "高级渲染"
},
"app.appearance-settings.color-theme.description": {
"message": "为 Modrinth App 选择偏好的色彩主题。"
},
"app.appearance-settings.color-theme.title": {
"message": "色彩主题"
},
"app.appearance-settings.default-landing-page.home": {
"message": "首页"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "最小化 App"
},
"app.appearance-settings.native-decorations.description": {
"message": "使用操作系统的标题栏和窗口控件。需要重启应用。"
},
"app.appearance-settings.native-decorations.title": {
"message": "系统窗口框架"
},
"app.appearance-settings.show-play-time.description": {
"message": "显示你在每个实例中游玩的时间。"
},
@@ -143,18 +143,6 @@
"app.ads-consent.title": {
"message": "關於你的隱私,以及廣告如何幫助 Modrinth"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "啟用視覺效果(如背景模糊),若無硬體加速可能會降低效能。"
},
"app.appearance-settings.advanced-rendering.title": {
"message": "進階繪製"
},
"app.appearance-settings.color-theme.description": {
"message": "選擇 Modrinth App 使用的色彩主題。"
},
"app.appearance-settings.color-theme.title": {
"message": "色彩主題"
},
"app.appearance-settings.default-landing-page.home": {
"message": "首頁"
},
@@ -179,12 +167,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "最小化應用程式"
},
"app.appearance-settings.native-decorations.description": {
"message": "使用作業系統的標題列和視窗控制項。需要重新啟動應用程式。"
},
"app.appearance-settings.native-decorations.title": {
"message": "系統視窗外框"
},
"app.appearance-settings.show-play-time.description": {
"message": "顯示你在各個實例的遊玩時數。"
},
-4
View File
@@ -5,7 +5,6 @@ import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker'
import { VueQueryPlugin } from '@tanstack/vue-query'
import FloatingVue from 'floating-vue'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from '@/App.vue'
@@ -21,8 +20,6 @@ const vueScan = new VueScanPlugin({
playSound: false, // Play sound on each render
})
const pinia = createPinia()
let app = createApp(App)
Sentry.init({
@@ -35,7 +32,6 @@ Sentry.init({
app.use(VueQueryPlugin)
app.use(vueScan)
app.use(router)
app.use(pinia)
app.use(FloatingVue, {
themes: {
'ribbit-popout': {
+21 -14
View File
@@ -34,13 +34,14 @@ import {
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get_project, get_search_results_v3, get_version_many } from '@/helpers/cache.js'
import {
get_installed_project_ids as getInstalledProjectIds,
@@ -63,7 +64,6 @@ import {
createServerInstallContent,
provideServerInstallContent,
} from '@/providers/setup/server-install-content'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -109,7 +109,7 @@ const breadcrumbLabel = computed(() => {
),
})
})
const themeStore = useTheming()
const appSettings = useAppSettings()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
@@ -384,7 +384,7 @@ async function initInstanceContext() {
}
function setBrowseHideInstalledFlag(flag: 'hide_installed_modpacks', value: boolean) {
themeStore.featureFlags[flag] = value
appSettings.featureFlags[flag] = value
getSettings()
.then((settings) => {
settings.feature_flags[flag] = value
@@ -394,7 +394,7 @@ function setBrowseHideInstalledFlag(flag: 'hide_installed_modpacks', value: bool
}
const hideInstalledModpacks = computed({
get: () => themeStore.getFeatureFlag('hide_installed_modpacks'),
get: () => appSettings.getFeatureFlag('hide_installed_modpacks'),
set: (value: boolean) => setBrowseHideInstalledFlag('hide_installed_modpacks', value),
})
@@ -530,13 +530,20 @@ const {
})
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
const handleOffline = () => {
debugLog('went offline')
offline.value = true
})
window.addEventListener('online', () => {
}
const handleOnline = () => {
debugLog('went online')
offline.value = false
}
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
onBeforeUnmount(() => {
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
})
const messages = defineMessages({
@@ -742,7 +749,7 @@ const installContext = computed(() => {
queuedCount: queuedServerInstallCount.value,
selectedProjects: selectedServerInstallProjects.value,
isInstallingSelected: isInstallingQueuedServerInstalls.value,
skipNonEssentialWarnings: themeStore.getFeatureFlag('skip_non_essential_warnings'),
skipNonEssentialWarnings: appSettings.getFeatureFlag('skip_non_essential_warnings'),
installProgress: queuedInstallProgress.value,
clearQueued: clearQueuedServerInstalls,
clearSelected: clearQueuedServerInstalls,
@@ -1222,9 +1229,9 @@ function getProjectBrowseQuery() {
}
const advancedFiltersCollapsed = computed({
get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
get: () => appSettings.getFeatureFlag('advanced_filters_collapsed'),
set: (value) => {
themeStore.featureFlags['advanced_filters_collapsed'] = value
appSettings.featureFlags['advanced_filters_collapsed'] = value
getSettings()
.then((settings) => {
settings.feature_flags['advanced_filters_collapsed'] = value
@@ -1235,9 +1242,9 @@ const advancedFiltersCollapsed = computed({
})
const dismissedPhotosensitivityFilterWarning = computed({
get: () => themeStore.getFeatureFlag('dismissed_photosensitivity_filter_warning'),
get: () => appSettings.getFeatureFlag('dismissed_photosensitivity_filter_warning'),
set: (value) => {
themeStore.featureFlags['dismissed_photosensitivity_filter_warning'] = value
appSettings.featureFlags['dismissed_photosensitivity_filter_warning'] = value
getSettings()
.then((settings) => {
settings.feature_flags['dismissed_photosensitivity_filter_warning'] = value
+7 -7
View File
@@ -1,20 +1,20 @@
<script setup lang="ts">
import { HomeIcon, PlusIcon } from '@modrinth/assets'
import { PlayIcon, PlusIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, inject, onActivated, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import LibrarySection from '@/components/ui/library/index.vue'
import WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { toError } from '@/helpers/errors'
import { list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { injectOnboardingChecklist } from '@/providers/onboarding-checklist'
import { useTheming } from '@/store/theme.ts'
defineOptions({
name: 'LibraryPage',
@@ -25,7 +25,7 @@ const { formatMessage } = useVIntl()
const { hasCreatedInstance, isReady } = injectOnboardingChecklist()
const showCreationModal = inject<() => void>('showCreationModal')
const pageOptions = ref<InstanceType<typeof ContextMenu>>()
const themeStore = useTheming()
const appSettings = useAppSettings()
const messages = defineMessages({
home: {
@@ -43,7 +43,7 @@ const homeBreadcrumb = useRootBreadcrumb({
id: 'home',
label: formatMessage(messages.home),
to: '/',
visual: { type: 'icon', component: HomeIcon },
visual: { type: 'icon', component: PlayIcon },
})
onActivated(homeBreadcrumb.reset)
@@ -102,11 +102,11 @@ function handlePageOption({ option }: { option: string }) {
<div
v-else-if="isReady"
data-library-page-background
class="flex flex-col gap-6 p-6"
class="flex flex-col gap-3 p-6"
@contextmenu="openPageContextMenu"
>
<RecentWorldsList
v-if="recentInstances?.length > 0 && themeStore.getFeatureFlag('worlds_in_home')"
v-if="recentInstances?.length > 0 && appSettings.getFeatureFlag('worlds_in_home')"
:recent-instances="recentInstances"
/>
<LibrarySection :instances="instances" />
+1 -1
View File
@@ -16,7 +16,7 @@ const client = injectModrinthClient()
useRootBreadcrumb({
slot: 'root',
id: 'servers',
label: 'Servers',
label: 'Hosting',
to: '/hosting/manage/',
visual: { type: 'icon', component: ServerStackIcon },
})
+4 -4
View File
@@ -32,6 +32,8 @@ import EarsModIcon from '@/assets/skins/ears-mod.png'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { check_reachable, get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
@@ -58,8 +60,6 @@ import {
} from '@/helpers/skins.ts'
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/state'
import { appMessages } from '@/utils/app-messages'
useRootBreadcrumb({
@@ -217,7 +217,7 @@ const { addNotification, handleError } = notifications
const auth = injectAuth()
const client = injectModrinthClient()
const themeStore = useTheming()
const appSettings = useAppSettings()
const skins = ref<Skin[]>([])
const capes = ref<Cape[]>([])
const offline = ref(!navigator.onLine)
@@ -339,7 +339,7 @@ const skinTexture = computedAsync(async () => {
})
const capeTexture = computed(() => currentCape.value?.texture)
const skinVariant = computed(() => selectedSkin.value?.variant)
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
const skinNametag = computed(() => (appSettings.hideNametagSkinsPage ? undefined : username.value))
const isSkinManagementReadOnly = computed(
() =>
!!currentUser.value &&
+17 -11
View File
@@ -11,19 +11,20 @@
>
<template #project-actions="{ project }">
<Button
v-if="project.minecraft_server == null"
type="outlined"
class="!text-brand [&>svg]:!text-brand !shadow-[inset_0_0_0_1px_var(--color-brand)]"
:disabled="isProjectInstalling(project.id)"
@click.stop="installProject(project)"
>
<SpinnerIcon v-if="isProjectInstalling(project.id)" class="animate-spin" />
<DownloadIcon v-else-if="project.project_type === 'modpack'" />
<DownloadIcon v-else-if="project.project_types.includes('modpack')" />
<PlusIcon v-else />
{{
formatMessage(
isProjectInstalling(project.id)
? commonMessages.installingLabel
: project.project_type === 'modpack'
: project.project_types.includes('modpack')
? commonMessages.installButton
: messages.installToInstance,
)
@@ -96,7 +97,7 @@ function setProjectInstalling(projectId: string, installing: boolean): void {
installingProjectIds.value = next
}
async function installProject(project: Labrinth.Projects.v2.Project): Promise<void> {
async function installProject(project: Labrinth.Projects.v3.Project): Promise<void> {
if (isProjectInstalling(project.id)) return
setProjectInstalling(project.id, true)
@@ -130,14 +131,19 @@ const userProfile = provideUserProfile({
unblockUser: unblock_user,
})
const userId = computed(() => {
const value = route.params.user
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
})
const projectType = computed(() => {
const value = route.params.projectType
return Array.isArray(value) ? value[0] : value
})
function readRouteParam(param: unknown): string | undefined {
const value = Array.isArray(param) ? param[0] : param
return typeof value === 'string' && value.length > 0 ? value : undefined
}
const userId = ref(readRouteParam(route.params.user) ?? '')
watch(
() => readRouteParam(route.params.user),
(next) => {
if (next) userId.value = next
},
)
const projectType = computed(() => readRouteParam(route.params.projectType))
function getCachedUserSummary(id: string) {
return queryClient.getQueryData<Labrinth.Users.v3.User>(['users', 'summary', id])
@@ -8,7 +8,7 @@
:layout-mode="isContainedServerRoute ? 'contained' : 'page'"
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
:show-copy-id-action="appSettings.devMode"
:auth-user="authUser"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
@@ -61,17 +61,17 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
import { provideBreadcrumbParent, useBreadcrumb } from '@/providers/breadcrumbs'
import { useTheming } from '@/store/theme'
const route = useRoute()
const router = useRouter()
const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const themeStore = useTheming()
const appSettings = useAppSettings()
const { formatMessage } = useVIntl()
const isContainedServerRoute = computed(() => route.name === 'ServerManageOverview')
@@ -16,6 +16,7 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import {
@@ -34,7 +35,6 @@ import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { injectAppEvents } from '@/providers/app-events'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
import type { Manifest } from '../../../../helpers/types'
import { instanceKeys } from '../../query-options.ts'
@@ -47,12 +47,12 @@ const filePicker = injectFilePicker()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const debug = useDebugLogger('AppInstallationSettings')
const themeStore = useTheming()
const appSettings = useAppSettings()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const managedContentPolicy = useManagedContentPolicy(instance)
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
appSettings.getFeatureFlag('skip_non_essential_warnings'),
)
debug('metadata load: start', {
@@ -127,6 +127,7 @@ import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { useAppEvent } from '@/composables/use-app-event'
import { type FeatureFlag, useAppSettings } from '@/composables/use-app-settings.ts'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import {
@@ -149,8 +150,6 @@ import type { CacheBehaviour } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { type AppEventPayload, injectAppEvents } from '@/providers/app-events'
import { injectContentInstall } from '@/providers/content-install'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme'
import { injectInstancePage } from '../instance-context'
import { instanceContentQueryOptions, instanceKeys } from '../query-options'
@@ -234,10 +233,10 @@ const { installingItems, installRevisionByInstance, installFailureRevisionByInst
const router = useRouter()
const queryClient = useQueryClient()
const debug = useDebugLogger('Mods:ContentUpdate')
const themeStore = useTheming()
const appSettings = useAppSettings()
const skipUnknownFileWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
appSettings.getFeatureFlag('skip_non_essential_warnings'),
)
const instancePage = injectInstancePage()
@@ -759,7 +758,7 @@ async function handleUploadFiles() {
}
function confirmUnknownFileInstallation(fileName: string) {
if (themeStore.getFeatureFlag(skipUnknownFileWarningFeatureFlag)) {
if (appSettings.getFeatureFlag(skipUnknownFileWarningFeatureFlag)) {
return Promise.resolve(true)
}
@@ -779,7 +778,7 @@ function resolveUnknownFileWarning(confirmed: boolean) {
async function handleUnknownFileContinue(dontShowAgain: boolean) {
if (dontShowAgain) {
themeStore.featureFlags[skipUnknownFileWarningFeatureFlag] = true
appSettings.featureFlags[skipUnknownFileWarningFeatureFlag] = true
try {
const settings = await getSettings()
settings.feature_flags[skipUnknownFileWarningFeatureFlag] = true
@@ -122,7 +122,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, type ComputedRef, onUnmounted, ref, shallowRef, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
@@ -133,6 +133,8 @@ import {
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { handleSevereError } from '@/composables/use-error.js'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { toError } from '@/helpers/errors'
@@ -159,8 +161,6 @@ import type { ServerStatus } from '@/helpers/worlds'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import InstanceAdmonitions from './components/admonitions/index.vue'
import InstancePageHeader from './components/page-header/index.vue'
@@ -216,8 +216,8 @@ watch(
},
{ immediate: true },
)
const themeStore = useTheming()
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
const appSettings = useAppSettings()
const showInstancePlayTime = computed(() => appSettings.getFeatureFlag('show_instance_play_time'))
const online = useOnline()
const offline = computed(() => !online.value)

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