Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 8ae3e61993 fix: qa 2026-07-31 18:08:42 +01:00
Calum H. (IMB11) 2562b01483 fix: passkey qa 2026-07-31 17:27:53 +01:00
Calum H. (IMB11) dea690c147 fix: split broken 2026-07-31 17:17:02 +01:00
Calum H. (IMB11) eb232cf627 fix: lint 2026-07-31 15:06:23 +01:00
Calum H. (IMB11) 2b2ebbaedc fix: v-tooltip 2026-07-31 14:57:43 +01:00
Calum H. (IMB11) 4a97836998 fix: qa 2026-07-31 14:54:00 +01:00
Calum H. (IMB11) 9669d9ef5c fix: remove text-contrast 2026-07-31 14:48:13 +01:00
Calum H. (IMB11) e7b86e9418 fix: qa 2026-07-31 14:37:26 +01:00
Calum H. (IMB11) b5a5d748ee fix: qa 2026-07-31 14:25:27 +01:00
Calum H. (IMB11) 9859e8310e fix: qa 2026-07-31 14:08:20 +01:00
Calum H. (IMB11) 501757b9d9 fix: qa 2026-07-31 14:07:21 +01:00
Calum H. (IMB11) e1774217c6 fix: qa 2026-07-31 13:53:24 +01:00
Calum H. (IMB11) bc1c7c80c6 fix: qa 2026-07-31 13:52:45 +01:00
Calum H. (IMB11) 23933c1f49 fix: qa 2026-07-31 13:45:26 +01:00
Calum H. (IMB11) da52062fab fix: qa 2026-07-31 13:35:45 +01:00
Calum H. (IMB11) bca1e511ec refactor: part 1 2026-07-31 12:41:47 +01:00
Calum H. (IMB11) bdc2a60f9b Revert "refactor"
This reverts commit 96d65902d7.
2026-07-31 11:57:37 +01:00
Calum H. (IMB11) 96d65902d7 refactor 2026-07-31 11:49:02 +01:00
Calum H. (IMB11) 6e0fb0d192 fix: undo refactor for fresh restart 2026-07-31 11:33:46 +01:00
Calum H. (IMB11) aad7386023 refactor part 2 2026-07-31 11:16:06 +01:00
Calum H. (IMB11) f7ed9e74e0 refactor part 1 2026-07-31 10:43:14 +01:00
Calum H.andGitHub 1f1ecc728a Merge branch 'main' into cal/new-button-api-cleanup 2026-07-31 10:03:38 +01:00
Calum H. (IMB11) 3ad76ba505 docs: buttons.md 2026-07-31 10:01:08 +01:00
Calum H. (IMB11) 78ca6364d7 fix: lint 2026-07-31 09:57:39 +01:00
Calum H. (IMB11) 31cadd1f23 fix: rename default ->md 2026-07-31 09:57:09 +01:00
Prospector 65a3ac4b34 changelog 2026-07-30 23:10:28 -07:00
ProspectorandGitHub 295db3fb7a only refresh tokens and clear auth cookies on auth fails, not any ran… (#6947)
only refresh tokens and clear auth cookies on auth fails, not any random error
2026-07-30 23:10:08 -07:00
Michael H. 7abe6f8c73 feat: sccache on windows 2026-07-30 20:27:08 +02:00
Michael H. c5ce5bc9b3 fix: create clickhouse db in tests only 2026-07-30 20:08:49 +02:00
Michael H. 572b8027ff fix: clickhouse migrations should not run normally 2026-07-30 19:23:10 +02:00
aecsocketandGitHub 0a53211389 fix: analytics event endpoint (#6938)
* fix: analytics event endpoint

* split admin-only routes into internal

* fix pr comment
2026-07-30 04:40:52 +00:00
Prospector 22b2b7746e changelog 2026-07-29 14:34:41 -07:00
Calum H. (IMB11) 4989bffa9a fix: build 2026-07-29 22:18:27 +01:00
39b3e20d0a fix: feedback file limit (#6931)
* fix: feedback file limit

* prepr

* prepr again

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-29 20:47:05 +00:00
Calum H.andGitHub 3892b62451 fix: reinstall jobs not cleaning up if instance deleted (#6927) 2026-07-29 20:31:26 +00:00
Calum H.andGitHub 5ffffec2e5 fix: gracefully handle shared instance icons 404ing (#6928)
* fix: icon handling

* fix: cleanup impl

* fix: fmt
2026-07-29 20:30:52 +00:00
Truman GaoandGitHub a0724cc5fb feat: dont show analytics incomplete current day revenue (#6933) 2026-07-29 20:26:25 +00:00
ProspectorandGitHub a0320f425e install version matching filters if possible (#6932) 2026-07-29 13:26:39 -07:00
Michael H. 06262e7bb1 feat: clickhouse partitioning and header TTL 2026-07-29 22:10:20 +02:00
ProspectorandGitHub 9ccaab5fad add toggle to hide already-installed modpacks (#6917)
* add toggle to hide already-installed modpacks, on by default

* make false by default
2026-07-29 17:54:21 +00:00
Calum H. (IMB11) 4cf6135505 a11y: pass 2026-07-29 14:32:27 +01:00
Calum H. (IMB11) 2a6db458c4 fix: storybook 2026-07-29 14:29:15 +01:00
Calum H.andGitHub f8aa16f86e Merge branch 'main' into cal/new-button-api-cleanup
Signed-off-by: Calum H. <calum@modrinth.com>
2026-07-29 14:26:40 +01:00
Calum H. (IMB11) e1c33ad2da fix: qa 2026-07-29 14:05:32 +01:00
ProspectorandGitHub 39745086fa fix warning when passing extraneous props to floating action bar (#6919) 2026-07-29 13:02:40 +00:00
Truman GaoandGitHub a8dcfa8a5d fix: download modal suggested dep can use wrong mc version (#6925) 2026-07-29 12:44:34 +00:00
Truman GaoandGitHub 0f6a74b87f fix: pats page code invalidating in same session (#6924) 2026-07-29 12:44:30 +00:00
ProspectorandGitHub d5b763c8e3 Fix tauri overlay in modal closing on drag (#6920)
* Fix tauri overlay in modal closing on drag

* prepr
2026-07-29 12:44:28 +00:00
ProspectorandGitHub 7a98f45c3d add installation settings tooltip (#6916) 2026-07-29 12:44:16 +00:00
ProspectorandGitHub 0ea47ad7ef add new instance button (#6918) 2026-07-29 12:43:17 +00:00
ProspectorandGitHub cc804625ec Replace final usage of legacy modal (#6921)
* Replace final usage of legacy modal

* prepr
2026-07-29 12:42:59 +00:00
Prospector 20235b7d32 changelog 2026-07-28 16:36:16 -07:00
ProspectorandGitHub 920dc8dcbb Fix oauth appliction icon upload (#6913) 2026-07-28 16:30:21 -07:00
Calum H.andGitHub 30f64c0020 fix: remove dummy data (#6915) 2026-07-28 16:30:14 -07:00
ProspectorandGitHub cfafa25ed4 fix moderation messages + add legacy message (#6914) 2026-07-28 16:30:05 -07:00
Prospector 7495b99ddd fix changelog 2026-07-28 15:06:12 -07:00
Prospector 800271a606 0.17.1 2026-07-28 14:01:28 -07:00
Michael H. 36791a33d8 fix: app build ci 2026-07-28 22:59:26 +02:00
Prospector 20ec70b917 comment out 0.17.0 changelog 2026-07-28 13:58:21 -07:00
Prospector ab6edbee48 changelog 2026-07-28 13:47:48 -07:00
b17523e7fd refactor: breadcrumbs system (#6912)
* refactor: breadcrumbs system

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: loading flashes

* fix: qa

* fix: prepr

* fix: qa

* fix: change icon

* use modrinth logo in header for compactness

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-28 20:38:16 +00:00
f89c116bf8 feat: blocking frontend (#6911)
* feat: blocking api interfaces

* feat: block action on user pages + shared instance flows

* feat: safety settings subpage

* feat: interaction source settings

* feat: finish social settings

* feat: profile settings xplat

* fix: prepr

* feat: click on friends

* default instance -> game options & fix feature flag width

* fix: scroll indicators

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-28 20:02:58 +00:00
ProspectorandGitHub cbb31f31c0 cleaup app layout, add missing translations to it (#6904)
* cleaup app layout, add missing translations to it

* remove feature flag in backend

* add app-messages

* clean up nav slightly

* allow more than 3 quick instances

* make quick instances resizable
2026-07-28 19:42:08 +00:00
ThatGravyBoatandGitHub fe69e04785 feat: moderation settings (#6895)
* feat: moderation settings

* chore: move moderation specific feature flag to settings

* feat: force open collapsed regions if opened in new tab

* chore: remove unused field

* chore: run prepr
2026-07-28 19:38:56 +00:00
Calum H.andGitHub 82348fe40a qa: shared instances post release (#6893)
* fix: 20 user cap

* qa: invite modal dropdown + other + clamp limits

* fix: hide filters if just one type

* feat: update req banner

* feat: invite management frontend

* fix: moderation issues with shared instances

* feat: instance icon improvements

* fix: better offline handling

* fix: lint

* feat: show avatar in install to play modal

* feat: smaller qa points

* fix: fmt

* fix: fmt

* fix: yeet svg

* fix: 50 user limit
2026-07-28 14:10:12 +00:00
Calum H.andGitHub 5d34e7902a refactor: app settings layout (#6891)
* refactor: app settings layout

* fix: privacy copy got reverted

* fix: i18n

* fix: modal title

* fix: rev comments
2026-07-28 14:09:41 +00:00
3c8bb0923d fix authorized apps settings, redesign, fix retro theme vars (#6900)
* fix authorized apps settings, redesign, fix retro theme vars

* merge

* fix: retro in app

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-07-28 12:16:05 +00:00
7c4190fb9c feat: move users page to xplat page system (#6889)
* feat: move users page to xplat

* feat: stop doing tauri plugin open for user links

* feat: qa

* fix: qa

* fix: qa

* fix: comment

* fix: prepr

* prepr

* prepr

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-27 20:02:33 +00:00
SychicandGitHub ebc7cac8b0 feat(labrinth): user blocking (#6837)
* feat(labrinth): user blocking

still wip, nothing is hooked up

* style(labrinth): cargo fmt

* refactor(labrinth): move block/unblock routes to public api

* feat(labrinth): block friend requests if blocked

* feat(labrinth): remove friend/request on block

* feat(labrinth): get blocked users endpoint
2026-07-27 19:41:57 +00:00
François-Xavier TalbotandGitHub 224c28f29d feat: more details linked account info for support/moderation (#6897)
* feat: show discord + steam ID + github profile in "Show details" user modal

* pnpm intl:extract
2026-07-27 19:20:38 +00:00
aecsocketandGitHub e192f6a0af feat: define default search vars in env (#6902)
* feat: define default search vars in env

* fix
2026-07-27 19:17:14 +00:00
ProspectorandGitHub c938ca3ceb update toggle filter design (#6888)
change toggle filters to just use exclude-only filters, same design as the others.
2026-07-27 11:49:52 -07:00
François-Xavier TalbotandGitHub 20adb0f16a fix(labrinth): let through emails when neverbounce hangs or fails (#6898)
* fix(labrinth): let through emails when neverbounce hangs or fails

* chore(ci): stack size
2026-07-27 18:10:54 +00:00
Calum H.andGitHub 354940a8c6 fix: search resetting to page 1 (#6896) 2026-07-27 15:09:13 +00:00
4c2e64e31f fix: try fix install jobs being bricked/improve tracing (#6890)
* fix: try fix install jobs being bricked/improve tracing

* fix: rev cmts

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-27 14:47:46 +00:00
Modrinth BotandGitHub 372e2bf47b New translations from Crowdin (main) (#6892) 2026-07-27 10:46:42 +00:00
Magnus JensenandGitHub 36d786f504 Fix vertical mis-aligned badge in notifcations (#6887)
vertical align badge
2026-07-26 20:25:19 +00:00
Prospector 1a1e1ea448 changelog 2026-07-26 12:22:50 -07:00
ProspectorandGitHub 24240d514a update 24-48hrs -> within a week to give more realistic estimate (#6886) 2026-07-26 12:06:31 -07:00
aecsocketandGitHub 158de019a6 fix: search indexing performance and batching (#6521)
* Add consume batching delay

* maybe fix

* max batch size

* more logging

* parallelize remove tasks

* delete/upsert project/version messages

* prepare

* more logging

* log number of docs

* try more targeted, homogenous version change ops

* ensure only necessary fields are serialized into typesense

* disable index background task

* wip: script changes

* don't facet by project id

* fix

* fix clippy

* batch by document and dedup loaders

* fix test

* wip: projects/versions collections

* clean up SearchBackend interface

* cleanup pass

* cleanup pass 2

* standardise fn names

* cleanup pass

* fix compile

* factor out filter rewriting

* query perf

* put categories into the project doc

* wip: search filter AST

* doc comment

* more AST normalization

* (temp) convert search request errors into internal errors

* implement unary NOT

* tombi fmt

* fix tests

* revert request error

* try increase stack size
2026-07-26 14:55:00 +00:00
Prospector 54cc1898fa changelog 2026-07-25 18:32:56 -07:00
b96a1cfa58 fix: allow crowdin badges to skip image proxy (#6861)
Add crowdin to allowed hostnames

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-26 01:14:21 +00:00
59a794ebb1 Minor UX UI tweaks (#6596)
* feat(ux): Add letter counters to the bio and username

the username letter counter only appears after the 30th letter to not encourage people to have long usernames for no reason

* feat(ux): Added "Expand" button to bio on mobile

* Revert "feat(ux): Added "Expand" button to bio on mobile"

This reverts commit f7e036002a.

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-26 00:47:14 +00:00
b4380aee87 feat(moderation): modpack scanning improvements (#6843)
* feat(moderation): make modpack scanning improvements

* oopsie

* feat(moderation): batch size for modpack scan

* feat: i18n for modpack scan modal

* chore: remove debug line

* run intl:extract and prepr

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-26 00:14:09 +00:00
François-Xavier TalbotandGitHub 2cf71a53f2 chore(ci): increase rust stack size (#6874) 2026-07-26 00:11:08 +00:00
coolbotandGitHub 44be49416b feat: update private use moderation msgs and stage (#6872) 2026-07-26 00:10:48 +00:00
ProspectorandGitHub 7f07e66e4f fix: users not seeing moderation page banners and info 🤦 (#6882) 2026-07-25 17:10:24 -07:00
Michael H. 907db9e64a fix: remove sccache from turbo-ci 2026-07-25 00:01:27 +02:00
François-Xavier TalbotandGitHub 944f99a14e chore(labrinth): add extra logging on muralpay failed txn (#6870) 2026-07-24 23:53:37 +02:00
Michael H. a79736dba9 fix: explicitly install java on theseus build 2026-07-24 21:47:43 +02:00
Michael H. 6f0181d52f fix: app build but actually 2026-07-24 21:40:34 +02:00
Michael H. 70c544b048 fix: app build 2026-07-24 21:38:46 +02:00
Michael H. 5a80df3cd4 build: change runner profiles 2026-07-24 21:32:06 +02:00
Michael H.andGitHub 1e85ec8649 chore: switch to namespace runners (#6851)
* chore: switch to namespace runners

* fix: sudo

* fix: move to medium runners for frontend

* chore: bump all actions, remove old turbo cache

* fix: bump frontend runners to large

* fix: wrangler

* fix: wrangler version, again

* chore: namespace windows and mac runners

* fix: revert tombi bump

* chore: tweak caching
2026-07-24 21:27:52 +02:00
Calum H.andGitHub 1dd613e459 feat: changelog (#6863) 2026-07-24 20:04:57 +02:00
Calum H.andGitHub d632b7bdbc feat: shared instances blog (#6847)
* feat: shared instances blog

* fix: formatting

* fix: authors

* fix: update blog
2026-07-24 17:57:20 +00:00
Modrinth BotandGitHub eb8141134a New translations from Crowdin (main) (#6859) 2026-07-24 17:55:52 +00:00
Calum H.andGitHub cc0d3bf42c fix: post-release shared instances (#6860)
* fix: qa

* fix: lint
2026-07-24 17:54:00 +00:00
Calum H.andGitHub 5e155d69b6 feat: changelog app 0.16.0 (#6858) 2026-07-24 18:59:23 +02:00
Calum H.andGitHub 707722ded6 fix: better feedback for disabled by provider mural error (#6854)
* fix: better feedback for disabled by provider mural error

* fix: lint
2026-07-24 16:50:46 +00:00
Truman GaoandGitHub dfc2ed3783 fix: handling failures/invalid popup edge cases to show normal gdpr popup fallback (#6857)
* fix: remove drilling actions down iframes

* refactor: split up custom popup logic, trim it down a lot

* fix: bring back TCF useractioncomplete and USP/GPP sectionChange event listeners, and only using dom observer as the fallback

* remove open devtools

* format

* harden CPM fallbacks for unknown layouts

* consolidate timeouts

* refactor: rename functions to make sense

* fix: sidebar closed on launch does not properly set state
2026-07-24 16:48:52 +00:00
Calum H.andGitHub fbe70c6938 fix: pre-release shared instances fixes (#6856)
* fix: dedupe notifications

* fix: notifs + members btn robustness

* fix: temp fix for external file wrongly showing updated

* fix: fmt
2026-07-24 16:47:08 +00:00
Calum H.andGitHub cd56b98975 fix: broken link highlighting in changelog section (#6853)
* fix: broken link highlighting in changelog section

* fix: hide switch vers for external files
2026-07-24 15:54:30 +00:00
Calum H.andGitHub b06b14e0fb fix: tooltip issues in content tab for server panel (#6852) 2026-07-24 15:53:37 +00:00
Calum H.andGitHub a60c0dc395 Merge branch 'main' into cal/styled-button 2026-07-16 15:50:18 +01:00
Calum H. (IMB11) 72fa9dd131 feat: refactor button components 2026-07-16 15:26:16 +01:00
771 changed files with 51235 additions and 24919 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target.'cfg(windows)']
[target."cfg(windows)"]
rustflags = ["-C", "link-args=/STACK:16777220"]
[target.x86_64-pc-windows-msvc]
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
BUMP_TYPE: minor
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -47,7 +47,7 @@ jobs:
- name: Setup Node
if: steps.changes.outputs.changed == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
registry-url: https://registry.npmjs.org
@@ -63,7 +63,7 @@ jobs:
- name: Restore pnpm cache
if: steps.changes.outputs.changed == 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # 0.12.1
uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1
with:
workflow_id: all
access_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Post or update changelog comment
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
script: |
+3 -3
View File
@@ -12,14 +12,14 @@ jobs:
typos:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: crate-ci/typos@6ac2ebd1b93eade61faf7e12688ad87a073fea59 # v1.46.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
# see <https://github.com/influxdata/datafusion-udf-wasm/pull/275>
tombi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
with:
tool: tombi
+3 -3
View File
@@ -13,8 +13,8 @@ jobs:
shear:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
- uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
- run: cargo binstall --no-confirm cargo-shear
- run: cargo shear
+8 -36
View File
@@ -35,7 +35,7 @@ jobs:
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check if workflow runs on an internal branch
id: check-internal
@@ -72,19 +72,15 @@ jobs:
fi
docker:
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
env:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-medium-amd64' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
cache: false
@@ -92,30 +88,6 @@ jobs:
- name: Setup mold
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Cache Cargo registry and index
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-daedalus-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Build daedalus_client
run: cargo build --release --package daedalus_client
@@ -125,11 +97,11 @@ jobs:
cp target/release/daedalus_client apps/daedalus_client/docker-stage/daedalus_client
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with:
@@ -144,14 +116,14 @@ jobs:
org.opencontainers.image.licenses=MIT
- name: Login to GitHub Packages
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./apps/daedalus_client/docker-stage
file: ./apps/daedalus_client/Dockerfile
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
+65 -62
View File
@@ -39,14 +39,14 @@ concurrency:
jobs:
deploy:
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: namespace-profile-modrinth-frontend
permissions:
contents: read
deployments: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -90,24 +90,23 @@ jobs:
fi
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-cache-
cache: |
rust
pnpm
apt
- name: Set up Turborepo caching
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
- name: Inject build variables
working-directory: ./apps/frontend
@@ -136,7 +135,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@5657c9e888b4e2cc85f4d29143ea4131fde4a73a # v3.6.0
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: modrinth
@@ -146,50 +145,53 @@ jobs:
sourcemaps: ./apps/frontend/.output/server
url_prefix: '~/'
- name: Deploy Cloudflare Worker
id: wrangler
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
- name: Install Wrangler CLI
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.cmd }}
wranglerVersion: '4.112.0'
command: --version
- name: Deploy staging Cloudflare Worker preview
if: ${{ inputs.preview }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.staging_cmd }}
- parallel:
- name: Deploy Cloudflare Worker
id: wrangler
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
workingDirectory: ./apps/frontend
command: ${{ steps.meta.outputs.cmd }}
- name: Deploy persistent Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias ${{ inputs.branch_alias }} --var PREVIEW:true --var CF_PAGES_URL:${{ steps.meta.outputs.branch_alias_url }}
- name: Deploy staging Cloudflare Worker preview
if: ${{ inputs.preview }}
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
command: ${{ steps.meta.outputs.staging_cmd }}
- name: Deploy persistent staging Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.staging_branch_alias_cmd }}
- name: Deploy persistent Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
command: versions upload --preview-alias ${{ inputs.branch_alias }} --var PREVIEW:true --var CF_PAGES_URL:${{ steps.meta.outputs.branch_alias_url }}
- name: Deploy persistent staging Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
command: ${{ steps.meta.outputs.staging_branch_alias_cmd }}
- name: Purge cache
if: github.ref == 'refs/heads/prod'
@@ -206,16 +208,17 @@ jobs:
echo "${{ steps.meta.outputs.url }}" > deployment-url-production.txt
echo "${{ steps.meta.outputs.staging_url }}" > deployment-url-staging.txt
- name: Upload production deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-production
path: deployment-url-production.txt
- parallel:
- name: Upload production deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-production
path: deployment-url-production.txt
- name: Upload staging deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-staging
path: deployment-url-staging.txt
- name: Upload staging deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-staging
path: deployment-url-staging.txt
+29 -28
View File
@@ -39,7 +39,7 @@ jobs:
deploy-storybook:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
needs: metadata
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: namespace-profile-modrinth-frontend
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-storybook
cancel-in-progress: true
@@ -48,27 +48,21 @@ jobs:
deployments: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-cache-
cache: |
pnpm
- name: Install dependencies
working-directory: ./packages/ui
@@ -86,27 +80,34 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: echo "sha_short=${HEAD_SHA::8}" >> $GITHUB_OUTPUT
- name: Deploy Storybook preview
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
- name: Install Wrangler CLI
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
wranglerVersion: '4.112.0'
command: --version
- name: Deploy persistent Storybook preview
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
env:
NODE_OPTIONS: --max-old-space-size=6144
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias ${{ needs.metadata.outputs.branch-alias }}
- parallel:
- name: Deploy Storybook preview
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
- name: Deploy persistent Storybook preview
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
env:
NODE_OPTIONS: --max-old-space-size=6144
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
command: versions upload --preview-alias ${{ needs.metadata.outputs.branch-alias }}
comment:
if: |
+3 -3
View File
@@ -51,13 +51,13 @@ jobs:
CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.ref }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
@@ -90,7 +90,7 @@ jobs:
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
- name: Download translations from Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_sources: false
upload_translations: false
+3 -3
View File
@@ -56,13 +56,13 @@ jobs:
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.ref }}
fetch-depth: 2
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
@@ -83,7 +83,7 @@ jobs:
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
- name: Upload translations to Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_sources: true
upload_translations: false
+24 -38
View File
@@ -33,7 +33,7 @@ jobs:
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check if workflow runs on an internal branch
id: check-internal
@@ -70,22 +70,21 @@ jobs:
fi
docker:
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
env:
SQLX_OFFLINE: 'true'
GIT_HASH: ${{ github.sha }}
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '20G' || '' }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
RUST_MIN_STACK: '16777216'
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
cache: false
@@ -93,36 +92,23 @@ jobs:
- name: Setup mold
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Set up caches
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
rust
apt
- name: Configure sccache
if: needs.skip-if-clean.outputs.internal == 'true'
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Install build dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
#uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
#with:
# packages: cmake libcurl4-openssl-dev
# version: v2 # cache key
- name: Cache Cargo registry and index
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-labrinth-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Build labrinth
run: cargo build --profile release-labrinth --package labrinth
@@ -135,11 +121,11 @@ jobs:
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
@@ -158,14 +144,14 @@ jobs:
org.opencontainers.image.licenses=AGPL-3.0-only
- name: Login to GitHub Packages
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./apps/labrinth/docker-stage
file: ./apps/labrinth/Dockerfile
-41
View File
@@ -1,41 +0,0 @@
name: Prepare pnpm cache
on:
push:
paths:
- .github/workflows/prepare-pnpm-cache.yml
- package.json
- pnpm-lock.yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
jobs:
prepare:
if: github.repository_owner == 'modrinth'
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install dependencies
run: pnpm recursive install --frozen-lockfile
+39 -36
View File
@@ -44,61 +44,62 @@ jobs:
name: Build
env:
VITE_STRIPE_PUBLISHABLE_KEY: pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5
# SCCACHE_DIR: '/mnt/sccache'
# SCCACHE_CACHE_SIZE: '10G'
# SCCACHE_MULTILEVEL_CHAIN: 'disk,s3'
SCCACHE_S3_KEY_PREFIX: '${{ github.repository }}/'
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: 'sccache'
strategy:
fail-fast: false
matrix:
platform: [
blacksmith-6vcpu-macos-26,
blacksmith-8vcpu-windows-2025, # At time of writing, Windows 4 vCPU VMs don't seem to actually exist
blacksmith-4vcpu-ubuntu-2404,
platform:
[
namespace-profile-medium-amd64-macos,
namespace-profile-medium-amd64-windows,
namespace-profile-medium-amd64,
]
include:
- platform: blacksmith-6vcpu-macos-26
- platform: namespace-profile-medium-amd64-macos
artifact-target-name: universal-apple-darwin
- platform: blacksmith-8vcpu-windows-2025
- platform: namespace-profile-medium-amd64-windows
artifact-target-name: x86_64-pc-windows-msvc
- platform: blacksmith-4vcpu-ubuntu-2404
- platform: namespace-profile-medium-amd64
artifact-target-name: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
target: ${{ contains(matrix.artifact-target-name, 'darwin') && 'x86_64-apple-darwin' || '' }}
- name: Setup mold
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
node-version-file: .nvmrc
cache: 'pnpm'
cache: |
rust
pnpm
- name: Configure sccache
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Generate tauri-dev.conf.json
shell: bash
@@ -116,11 +117,16 @@ jobs:
EOF
- name: Install Linux build dependencies
if: contains(matrix.platform, 'ubuntu')
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
if: contains(matrix.artifact-target-name, 'linux')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: Setup Java
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
packages: libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
version: v1 # cache key
distribution: temurin
java-version: 17
- name: Setup Dasel
uses: jaxxstorm/action-install-gh-release@25e24d2d23ae098373794ef1d6faecb48ee52da8 # v3.0.0
@@ -128,7 +134,7 @@ jobs:
repo: TomWright/dasel
tag: v2.8.1
extension-matching: disable
rename-to: ${{ contains(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
rename-to: ${{ contains(matrix.artifact-target-name, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: Set application version and environment
@@ -147,14 +153,11 @@ jobs:
cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
- name: Install dependencies
run: pnpm install
- name: Set up Windows code signing
if: contains(matrix.platform, 'windows')
if: contains(matrix.artifact-target-name, 'windows')
shell: bash
run: |
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
@@ -165,7 +168,7 @@ jobs:
- name: Build macOS app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
if: contains(matrix.platform, 'macos')
if: contains(matrix.artifact-target-name, 'darwin')
env:
TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
@@ -180,7 +183,7 @@ jobs:
- name: Build Linux app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
if: contains(matrix.platform, 'ubuntu')
if: contains(matrix.artifact-target-name, 'linux')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -192,7 +195,7 @@ jobs:
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
if: contains(matrix.platform, 'windows')
if: contains(matrix.artifact-target-name, 'windows')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: namespace-profile-medium-amd64
env:
VERSION_TAG: ${{ github.event.workflow_run.head_branch }}
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Verify ref is a tag
env:
+24 -54
View File
@@ -22,7 +22,7 @@ jobs:
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check if workflow runs on an internal branch
id: check-internal
@@ -62,7 +62,7 @@ jobs:
build:
name: Lint and Test
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-turbo' || 'ubuntu-latest' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -76,54 +76,47 @@ jobs:
# since we don't want warnings to become errors
# while developing)
RUSTFLAGS: -Dwarnings
# sccache config (only populated for internal branches; secrets aren't
# available to forked PRs, and blacksmith stickydisk requires a
# blacksmith runner)
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
CARGO_INCREMENTAL: 0
# Redis config for tests
REDIS_TOPOLOGY: cluster
REDIS_CONNECTION_TYPE: multiplexed
REDIS_URL: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
# Avoid stack overflows in tests
RUST_MIN_STACK: 16777216
RUST_MIN_STACK: 134217728
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2
- name: Install build dependencies
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
with:
packages: cmake libcurl4-openssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
version: v2 # cache key
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Get pnpm store path
id: pnpm-store
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- name: Set up caches
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
path: ${{ steps.pnpm-store.outputs.store-path }}
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
pnpm-cache-
cache: |
rust
pnpm
apt
- name: Set up Turborepo caching
if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
- name: Install apt dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake libcurl4-openssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
with:
rustflags: ''
components: clippy, rustfmt
@@ -132,28 +125,8 @@ jobs:
- name: Setup mold
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Cache Cargo registry and index
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae #v5.0.5
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-turbo-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Setup binstall
uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
- name: Setup nextest
run: cargo binstall --no-confirm --secure --locked cargo-nextest@0.9.133
@@ -161,16 +134,13 @@ jobs:
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: Setup cargo-sqlx
uses: taiki-e/cache-cargo-install-action@f9eed3e4680f27610dc6d8c67be1b88593f7dade # v3.0.6
uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7
with:
tool: sqlx-cli@0.8.6
locked: false
no-default-features: true
features: rustls,postgres
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
- name: Install dependencies
run: pnpm install
Generated
+6 -3
View File
@@ -4806,7 +4806,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.5",
"system-configuration",
"tokio",
"tower-service",
@@ -5465,6 +5465,7 @@ dependencies = [
"bytes",
"censor",
"chrono",
"chumsky",
"clap 4.5.48",
"clickhouse",
"color-eyre",
@@ -5518,6 +5519,7 @@ dependencies = [
"serde_with",
"sha1 0.10.6",
"sha2",
"smallvec",
"spdx",
"sqlx",
"sqlx-tracing",
@@ -8014,7 +8016,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.32",
"socket2 0.5.10",
"socket2 0.6.5",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -8051,7 +8053,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.5",
"tracing",
"windows-sys 0.60.2",
]
@@ -11018,6 +11020,7 @@ dependencies = [
"heck 0.5.0",
"hickory-resolver 0.25.2",
"httpdate",
"image",
"indicatif",
"itertools 0.14.0",
"modrinth-content-management",
+2
View File
@@ -59,6 +59,7 @@ bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.42"
chumsky = "0.9.3"
cidre = { version = "0.15.0", default-features = false, features = [
"macos_15_0"
] }
@@ -192,6 +193,7 @@ sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
shlex = "1.3.0"
smallvec = "1.15.1"
spdx = "0.12.0"
sqlx = { version = "0.8.6", default-features = false }
sqlx-tracing = { path = "packages/sqlx-tracing" }
+202 -241
View File
@@ -10,45 +10,45 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
ExternalIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
NotepadTextIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
ServerStackIcon,
SettingsIcon,
ShirtIcon,
UserIcon,
WorldIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
ButtonLink,
commonMessages,
ContentInstallModal,
ContentUpdaterModal,
CreationFlowModal,
defineMessages,
I18nDebugPanel,
IconButton,
IntlFormatted,
LoadingBar,
NewsArticleCard,
NotificationPanel,
OverflowMenu,
PopupNotificationPanel,
provideModalBehavior,
provideModrinthClient,
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
TeleportOverflowMenu,
TextLogo,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
@@ -63,11 +63,9 @@ import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { $fetch } from 'ofetch'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
@@ -88,15 +86,16 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
import {
ads_consent_listener,
get_ads_consent_required,
hide_ads_window,
init_ads_window,
perform_ads_consent_action,
should_show_ads_consent_popup,
show_ads_window,
} from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
@@ -104,17 +103,13 @@ import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import {
can_current_user_use_shared_instances,
get as getInstance,
list,
run,
} from '@/helpers/instance'
import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
import { parse_modrinth_user_link } from '@/helpers/users'
import {
areUpdatesEnabled,
enqueueUpdateForInstallation,
@@ -136,6 +131,7 @@ import {
openAppUpdateChangelog,
setAppUpdateActions,
} from '@/providers/app-update.ts'
import { createBreadcrumbManager, provideBreadcrumbManager } from '@/providers/breadcrumbs'
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import {
provideAppUpdateDownloadProgress,
@@ -147,15 +143,30 @@ 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 { appMessages } from '@/utils/app-messages'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager)
const canNavigateBack = ref(false)
const canNavigateForward = ref(false)
function updateHistoryNavigationState() {
const historyState = window.history.state
canNavigateBack.value = historyState?.back != null
canNavigateForward.value = historyState?.forward != null
}
updateHistoryNavigationState()
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
@@ -265,7 +276,7 @@ providePageContext({
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
),
},
openExternalUrl: (url) => openUrl(url),
openExternalUrl: (url) => void openUrl(url),
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
@@ -288,8 +299,10 @@ const {
} = setupProviders(notificationManager, popupNotificationManager)
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const serverInvitePopupNotificationIds = new Set()
let liveNotificationGeneration = 0
let liveNotificationsEnabled = true
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -336,7 +349,7 @@ onMounted(async () => {
await useCheckDisableMouseover()
try {
unlistenAdsConsent = await ads_consent_listener(handleAdsConsentRequired)
handleAdsConsentRequired(await get_ads_consent_required())
handleAdsConsentRequired(await should_show_ads_consent_popup())
} catch (error) {
handleError(error)
}
@@ -399,6 +412,54 @@ const messages = defineMessages({
id: 'app.ads-consent.accept',
defaultMessage: 'Accept all',
},
home: {
id: 'app.nav.home',
defaultMessage: 'Home',
},
library: {
id: 'app.nav.library',
defaultMessage: 'Library',
},
modrinthHosting: {
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
},
createNewInstance: {
id: 'app.nav.create-new-instance',
defaultMessage: 'Create new instance',
},
modrinthAccount: {
id: 'app.nav.modrinth-account',
defaultMessage: 'Modrinth account',
},
signedInAs: {
id: 'app.nav.signed-in-as',
defaultMessage: 'Signed in as <user>{username}</user>',
},
signInToModrinthAccount: {
id: 'app.nav.sign-in-to-modrinth-account',
defaultMessage: 'Sign in to a Modrinth account',
},
restarting: {
id: 'app.restarting',
defaultMessage: 'Restarting...',
},
upgradeToModrinthPlus: {
id: 'app.nav.upgrade-to-modrinth-plus',
defaultMessage: 'Upgrade to Modrinth+',
},
news: {
id: 'app.news.title',
defaultMessage: 'News',
},
viewAllNews: {
id: 'app.news.view-all',
defaultMessage: 'View all news',
},
playingAs: {
id: 'app.sidebar.playing-as',
defaultMessage: 'Playing as',
},
})
function handleAdsConsentRequired(required) {
@@ -567,12 +628,6 @@ async function setupApp() {
settings.pending_update_toast_for_version = null
await setSettings(settings)
}
if (osType === 'windows') {
await processPendingSurveys()
} else {
console.info('Skipping user surveys on non-Windows platforms')
}
}
const stateFailed = ref(false)
@@ -616,6 +671,7 @@ router.beforeEach(() => {
routerToken = loading.begin()
})
router.afterEach((to, from, failure) => {
updateHistoryNavigationState()
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
@@ -757,6 +813,8 @@ const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginModal = ref()
const appSettingsModal = ref()
provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile())
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
@@ -786,16 +844,19 @@ async function validateSession(sessionToken) {
}
async function fetchCredentials() {
const hadSession = !!credentials.value?.session
const refreshId = ++credentialsRefreshId
credentials.value = undefined
const creds = await getCreds().catch(handleError)
if (refreshId !== credentialsRefreshId) return
if (!creds && hadSession) clearLiveNotifications()
if (creds && creds.user_id) {
if (creds.session && !(await validateSession(creds.session))) {
if (refreshId !== credentialsRefreshId) return
clearLiveNotifications()
await logout().catch(handleError)
if (refreshId !== credentialsRefreshId) return
@@ -806,6 +867,7 @@ async function fetchCredentials() {
if (refreshId !== credentialsRefreshId) return
}
credentials.value = creds ?? null
liveNotificationsEnabled = !!creds?.session
}
async function signIn(flow = 'sign-in') {
@@ -841,6 +903,7 @@ async function logOut() {
async function performLogOut() {
credentialsRefreshId++
credentials.value = undefined
clearLiveNotifications()
await logout().catch(handleError)
await fetchCredentials()
@@ -958,16 +1021,17 @@ async function declineServerInviteNotification(notification) {
function openServerInviteInviterProfile(inviterName) {
if (!inviterName) return
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
void router.push(`/user/${encodeURIComponent(inviterName)}`)
}
async function handleLiveNotification(notification) {
if (!notification?.body || notification.read) return
if (!liveNotificationsEnabled || !notification?.body || notification.read) return
if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) return
if (notification.body.type === 'server_invite') {
if (displayedServerInviteNotifications.has(notification.id)) return
const generation = liveNotificationGeneration
displayedServerInviteNotifications.add(notification.id)
const serverName =
@@ -975,8 +1039,9 @@ async function handleLiveNotification(notification) {
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
if (generation !== liveNotificationGeneration) return
addPopupNotification({
const popupNotification = addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
@@ -989,9 +1054,21 @@ async function handleLiveNotification(notification) {
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
serverInvitePopupNotificationIds.add(popupNotification.id)
}
}
function clearLiveNotifications() {
liveNotificationGeneration++
liveNotificationsEnabled = false
for (const id of serverInvitePopupNotificationIds) {
popupNotificationManager.removeNotification(id)
}
displayedServerInviteNotifications.clear()
serverInvitePopupNotificationIds.clear()
sharedInstanceInviteHandler.value?.clearNotifications()
}
async function handleCommand(e) {
if (!e) return
@@ -1385,8 +1462,11 @@ function handleClick(e) {
!target.href.startsWith('https://tauri.localhost') &&
!target.href.startsWith('http://tauri.localhost')
) {
const userPath = parse_modrinth_user_link(target.href)
const parsed = parseModrinthLink(target.href)
if (target.target !== '_blank' && parsed) {
if (userPath) {
void router.push(userPath)
} else if (target.target !== '_blank' && parsed) {
void openModrinthProjectLinkInApp(parsed)
} else {
openUrl(target.href)
@@ -1413,115 +1493,6 @@ function handleAuxClick(e) {
}
}
function cleanupOldSurveyDisplayData() {
const threeWeeksAgo = new Date()
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21)
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key.startsWith('survey-') && key.endsWith('-display')) {
const dateValue = new Date(localStorage.getItem(key))
if (dateValue < threeWeeksAgo) {
localStorage.removeItem(key)
}
}
}
}
async function openSurvey() {
if (!availableSurvey.value) {
console.error('No survey to open')
return
}
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const formId = availableSurvey.value.tally_id
const popupOptions = {
layout: 'modal',
width: 700,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
user_id: userId,
},
onOpen: () => console.info('Opened user survey'),
onClose: () => {
console.info('Closed user survey')
show_ads_window()
},
onSubmit: () => console.info('Active user survey submitted'),
}
try {
hide_ads_window()
if (window.Tally?.openPopup) {
console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
dismissSurvey()
window.Tally.openPopup(formId, popupOptions)
} else {
console.warn('Tally script not yet loaded')
show_ads_window()
}
} catch (e) {
console.error('Error opening Tally popup:', e)
show_ads_window()
}
console.info(`Found user survey to show with tally_id: ${formId}`)
window.Tally.openPopup(formId, popupOptions)
}
function dismissSurvey() {
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, new Date())
availableSurvey.value = undefined
}
async function processPendingSurveys() {
function isWithinLastTwoWeeks(date) {
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
return date >= twoWeeksAgo
}
cleanupOldSurveyDisplayData()
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const instances = (await list().catch(handleError)) ?? []
const isActivePlayer = instances.some(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
let surveys = []
try {
surveys = await $fetch('https://api.modrinth.com/v2/surveys')
} catch (e) {
console.error('Error fetching surveys:', e)
}
const surveyToShow = surveys.find(
(survey) =>
!!(
localStorage.getItem(`survey-${survey.id}-display`) === null &&
survey.type === 'tally_app' &&
((survey.condition === 'active_player' && isActivePlayer) ||
(survey.assigned_users?.includes(userId) && !survey.dismissed_users?.includes(userId)))
),
)
if (surveyToShow) {
availableSurvey.value = surveyToShow
} else {
console.info('No user survey to show')
}
}
provideAppUpdateDownloadProgress(appUpdateDownload)
</script>
@@ -1544,12 +1515,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="flex items-center gap-4 text-contrast font-semibold text-xl select-none cursor-default"
>
<RefreshCwIcon data-tauri-drag-region class="animate-spin w-6 h-6" />
Restarting...
{{ formatMessage(messages.restarting) }}
</span>
</div>
</Transition>
<Suspense>
<AppSettingsModal ref="settingsModal" />
<AppSettingsModal ref="appSettingsModal" />
</Suspense>
<Suspense>
<ModrinthAccountRequiredModal ref="modrinthLoginModal" :request-auth="requestModrinthAuth" />
@@ -1567,27 +1538,24 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
/>
<UnknownPackWarningModal ref="unknownPackWarningModal" />
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.5rem] w-[--left-bar-width]"
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.25rem] w-[--left-bar-width]"
>
<NavButton v-tooltip.right="'Home'" to="/">
<NavButton v-tooltip.right="formatMessage(messages.home)" to="/">
<HomeIcon />
</NavButton>
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
to="/browse/modpack"
:is-primary="() => route.path.startsWith('/browse') && !route.query.i"
:is-subpage="(route) => route.path.startsWith('/project') && !route.query.i"
>
<CompassIcon />
</NavButton>
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
<ChangeSkinIcon />
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
<ShirtIcon />
</NavButton>
<NavButton
v-tooltip.right="'Library'"
v-tooltip.right="formatMessage(messages.library)"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
@@ -1600,19 +1568,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="'Modrinth Hosting'"
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
:is-subpage="(r) => r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/'"
>
<ServerStackIcon />
</NavButton>
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
<suspense>
<QuickInstanceSwitcher />
</suspense>
<NavButton
v-tooltip.right="'Create new instance'"
v-tooltip.right="formatMessage(messages.createNewInstance)"
:to="() => installationModal?.show()"
:disabled="offline"
>
@@ -1621,82 +1588,106 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div class="flex flex-grow"></div>
<NavButton
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
:to="() => $refs.settingsModal.show()"
:to="() => appSettingsModal?.show()"
>
<SettingsIcon />
</NavButton>
<OverflowMenu
<TeleportOverflowMenu
v-if="credentials?.user"
v-tooltip.right="`Modrinth account`"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
v-tooltip.right="formatMessage(messages.modrinthAccount)"
type="quiet"
size="xl"
label="More options"
:options="[
{
id: 'view-profile',
action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
label: formatMessage(messages.signedInAs, {
username: credentials.user.username,
}),
action: () => router.push(`/user/${encodeURIComponent(credentials.user.username)}`),
},
{
id: 'sign-out',
label: formatMessage(commonMessages.signOutButton),
tone: 'red',
action: () => logOut(),
color: 'danger',
},
]"
placement="right-end"
:distance="4"
>
<Avatar :src="credentials?.user?.avatar_url" alt="" size="32px" circle />
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
Signed in as
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
{{ credentials?.user?.username }}
</span>
<IntlFormatted
:message-id="messages.signedInAs"
:values="{ username: credentials?.user?.username }"
>
<template #user="{ children }">
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</span>
<ExternalIcon />
</template>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
<template #sign-out>
<LogOutIcon />
{{ formatMessage(commonMessages.signOutButton) }}
</template>
</TeleportOverflowMenu>
<NavButton
v-else
v-tooltip.right="'Sign in to a Modrinth account'"
v-tooltip.right="formatMessage(messages.signInToModrinthAccount)"
:to="() => requestSignIn()"
>
<LogInIcon class="text-brand" />
</NavButton>
</div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex min-w-0 flex-1 overflow-hidden p-3">
<ModrinthAppLogo class="h-full w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="flex shrink-0 items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<IconButton
type="outlined"
label="Go back"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateBack"
@click="router.back()"
>
<LeftArrowIcon />
</button>
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
<ChevronLeftIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateBack }"
/>
</IconButton>
<IconButton
type="outlined"
label="Go forward"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateForward"
@click="router.forward()"
>
<RightArrowIcon />
</button>
<ChevronRightIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateForward }"
/>
</IconButton>
</div>
<Breadcrumbs class="pt-[2px]" />
<Breadcrumbs />
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<ButtonStyled
<IconButton
v-if="!forceSidebar && themeStore.toggleSidebar"
:type="sidebarToggled ? 'standard' : 'transparent'"
circular
:type="sidebarToggled ? 'base' : 'quiet'"
label="Next image"
class="mr-3 transition-transform"
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
>
<button
class="mr-3 transition-transform"
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
>
<RightArrowIcon />
</button>
</ButtonStyled>
<RightArrowIcon />
</IconButton>
<div class="flex mr-3">
<Suspense>
<AppActionBar />
@@ -1715,28 +1706,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}"
>
<div class="app-viewport flex-grow router-view">
<transition name="popup-survey">
<div
v-if="availableSurvey"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
>
<h2 class="text-lg font-extrabold mt-0 mb-2">Hey there Modrinth user!</h2>
<p class="m-0 leading-tight">
Would you mind answering a few questions about your experience with Modrinth App?
</p>
<p class="mt-3 mb-4 leading-tight">
This feedback will go directly to the Modrinth team and help guide future updates!
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="openSurvey"><NotepadTextIcon /> Take survey</button>
</ButtonStyled>
<ButtonStyled>
<button @click="dismissSurvey"><XIcon /> No thanks</button>
</ButtonStyled>
</div>
</div>
</transition>
<SurveyPopup />
<div
class="loading-indicator-container h-8 fixed z-50 pointer-events-none"
:style="{
@@ -1800,7 +1770,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-base text-primary font-medium m-0">Playing as</h3>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
</h3>
<suspense>
<AccountsCard ref="accounts" />
</suspense>
@@ -1815,18 +1787,26 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
/>
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">
{{ formatMessage(messages.news) }}
</h3>
<div class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
:key="`news-${index}`"
:article="item"
/>
<ButtonStyled color="brand" size="large">
<a href="https://modrinth.com/news" target="_blank" class="my-4">
<NewspaperIcon /> View all news
</a>
</ButtonStyled>
<ButtonLink
type="colored"
color="brand"
size="xl"
href="https://modrinth.com/news"
target="_blank"
class="my-4"
>
<NewspaperIcon />
{{ formatMessage(messages.viewAllNews) }}
</ButtonLink>
</div>
</div>
</div>
@@ -1837,7 +1817,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="absolute bottom-[250px] w-full flex justify-center items-center gap-1 px-4 py-3 text-purple font-medium hover:underline z-10"
target="_blank"
>
<ArrowBigUpDashIcon class="text-2xl" /> Upgrade to Modrinth+
<ArrowBigUpDashIcon class="text-2xl" />
{{ formatMessage(messages.upgradeToModrinthPlus) }}
</a>
<PromotionWrapper />
</template>
@@ -2049,26 +2030,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
display: contents;
}
.popup-survey-enter-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.51, 1.08, 0.35, 1.15);
transform-origin: top center;
}
.popup-survey-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.68, -0.17, 0.23, 0.11);
transform-origin: top center;
}
.popup-survey-enter-from,
.popup-survey-leave-to {
opacity: 0;
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
@media (prefers-reduced-motion: no-preference) {
.nav-button-animated-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
@@ -4,13 +4,11 @@
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
>
<span>{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled color="brand">
<button color="primary" :disabled="loginDisabled" @click="login()">
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToMinecraft) }}
</button>
</ButtonStyled>
<Button type="colored" color="brand" :disabled="loginDisabled" @click="login()">
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToMinecraft) }}
</Button>
</div>
<Accordion
v-else
@@ -60,24 +58,28 @@
{{ account.profile.name }}
</p>
</button>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button
v-tooltip="formatMessage(messages.removeAccount)"
class="mr-2"
@click="logout(account.profile.id)"
>
<TrashIcon />
</button>
</ButtonStyled>
<IconButton
v-tooltip="formatMessage(messages.removeAccount)"
type="quiet"
color="red"
:label="formatMessage(messages.removeAccount)"
class="mr-2 hover:!bg-red focus-visible:!bg-red hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
@click="logout(account.profile.id)"
>
<TrashIcon />
</IconButton>
</div>
</template>
<div class="flex flex-col gap-2 px-2 pt-2">
<ButtonStyled v-if="accounts.length > 0" class="w-full">
<button :disabled="loginDisabled" @click="login()">
<PlusIcon />
{{ formatMessage(messages.addAccount) }}
</button>
</ButtonStyled>
<Button
v-if="accounts.length > 0"
class="w-full"
:disabled="loginDisabled"
@click="login()"
>
<PlusIcon />
{{ formatMessage(messages.addAccount) }}
</Button>
</div>
</div>
</Accordion>
@@ -95,8 +97,9 @@ import {
import {
Accordion,
Avatar,
ButtonStyled,
Button,
defineMessages,
IconButton,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth/ui'
import { Button, injectNotificationManager, TeleportOverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { useRouter } from 'vue-router'
@@ -36,27 +36,26 @@ const handleSearchContent = async () => {
<template>
<div class="joined-buttons">
<ButtonStyled>
<button @click="handleSearchContent">
<PlusIcon />
Install content
</button>
</ButtonStyled>
<ButtonStyled>
<OverflowMenu
:options="[
{
id: 'from_file',
action: handleAddContentFromFile,
},
]"
>
<DropdownIcon />
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</OverflowMenu>
</ButtonStyled>
<Button @click="handleSearchContent">
<PlusIcon />
Install content
</Button>
<TeleportOverflowMenu
label="More options"
:options="[
{
id: 'from_file',
label: 'Add from file',
action: handleAddContentFromFile,
},
]"
class="!w-auto !px-2.5 !rounded-xl"
>
<DropdownIcon />
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</TeleportOverflowMenu>
</div>
</template>
@@ -1,15 +1,15 @@
<template>
<div class="flex gap-2 items-center">
<ButtonStyled
<IconButton
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts"
v-tooltip="formatMessage(messages.viewActiveDownloads)"
type="quiet"
color="brand"
type="transparent"
circular
:label="formatMessage(messages.viewActiveDownloads)"
@click="openDownloadToast()"
>
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="openDownloadToast()">
<DownloadIcon />
</button>
</ButtonStyled>
<DownloadIcon />
</IconButton>
<div v-if="offline" class="flex items-center gap-1">
<UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
@@ -36,17 +36,23 @@
@show="showInstances = true"
@hide="showInstances = false"
>
<ButtonStyled type="transparent" circular size="small">
<button
v-tooltip="
showInstances
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
>
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
</button>
</ButtonStyled>
<IconButton
v-tooltip="
showInstances
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
class="!size-6"
type="quiet"
size="xs"
:label="
showInstances
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
>
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
</IconButton>
<template #popper>
<div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
<div
@@ -125,8 +131,8 @@ import {
TerminalSquareIcon,
UnplugIcon,
} from '@modrinth/assets'
import { IconButton } from '@modrinth/ui'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
@@ -2,7 +2,7 @@
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
class="min-w-0 overflow-hidden pl-4"
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter"
@@ -11,30 +11,48 @@
<div
ref="innerRef"
data-tauri-drag-region
class="flex w-fit items-center gap-1"
class="flex w-fit items-center gap-2 pr-4"
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="shrink-0 whitespace-nowrap text-primary"
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.slot">
<component
:is="index < breadcrumbs.length - 1 && breadcrumb.to ? RouterLink : 'span'"
v-bind="index < breadcrumbs.length - 1 && breadcrumb.to ? { to: breadcrumb.to } : {}"
:data-tauri-drag-region="index === breadcrumbs.length - 1 ? '' : undefined"
class="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-base font-medium leading-6"
:class="
index === breadcrumbs.length - 1
? 'cursor-default select-none text-contrast'
: 'text-primary hover:text-contrast'
"
:aria-current="index === breadcrumbs.length - 1 ? 'page' : undefined"
>
{{ resolveLabel(breadcrumb.name) }}
</router-link>
<span
v-else
<Avatar
v-if="breadcrumb.visual?.type === 'image'"
:src="breadcrumb.visual.src"
:alt="breadcrumb.visual.alt ?? breadcrumb.label"
:circle="breadcrumb.visual.circle"
:tint-by="breadcrumb.visual.tintBy ?? breadcrumb.id"
size="20px"
no-shadow
raised
class="inline-block shrink-0 align-middle"
:class="{ '!rounded-md': !breadcrumb.visual.circle }"
/>
<component
:is="breadcrumb.visual.component"
v-else-if="breadcrumb.visual?.type === 'icon'"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<span>{{ breadcrumb.label }}</span>
</component>
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1"
data-tauri-drag-region
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
{{ resolveLabel(breadcrumb.name) }}
</span>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5 shrink-0" />
class="size-5 shrink-0 text-primary"
/>
</template>
</div>
</div>
@@ -42,34 +60,13 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Avatar } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { injectBreadcrumbManager } from '@/providers/breadcrumbs'
interface Breadcrumb {
name: string
link?: string
query?: Record<string, string>
}
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed<Breadcrumb[]>(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
})
function resolveLabel(name: string): string {
return name.charAt(0) === '?' ? breadcrumbData.getName(name.slice(1)) : name
}
const { entries: breadcrumbs } = injectBreadcrumbManager()
// Overflow detection
const outerRef = ref<HTMLDivElement | null>(null)
@@ -83,9 +80,13 @@ let stopping = false
function checkOverflow() {
if (!outerRef.value || !innerRef.value) return
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
const outerStyles = window.getComputedStyle(outerRef.value)
const horizontalPadding =
Number.parseFloat(outerStyles.paddingLeft) + Number.parseFloat(outerStyles.paddingRight)
const availableWidth = outerRef.value.clientWidth - horizontalPadding
const overflow = innerRef.value.scrollWidth - availableWidth
isOverflowing.value = overflow > 0
overflowAmount.value = overflow + 12
overflowAmount.value = overflow
}
function onMouseEnter() {
@@ -9,7 +9,13 @@ import {
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui'
import {
Button,
ButtonLink,
Collapsible,
IconButton,
injectNotificationManager,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons'
@@ -273,12 +279,10 @@ async function copyToClipboard(text) {
</template>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
</ButtonStyled>
<ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled>
<ButtonLink :href="supportLink" @click="errorModal.hide()"
><ChatIcon /> Get support</ButtonLink
>
<Button v-if="closable" @click="errorModal.hide()"><XIcon /> Close</Button>
</div>
<template v-if="hasDebugInfo">
<div class="flex flex-col gap-2">
@@ -307,16 +311,15 @@ async function copyToClipboard(text) {
>
{{ debugInfo }}
</div>
<ButtonStyled circular>
<button
v-tooltip="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</button>
</ButtonStyled>
<IconButton
v-tooltip="'Copy debug info'"
:label="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</IconButton>
</div>
</Collapsible>
</div>
@@ -1,7 +1,7 @@
<script setup>
import { XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessages,
FileTreeSelect,
@@ -286,18 +286,14 @@ function isExportCandidateDisabled(path) {
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="exportPack">
<PackageIcon />
{{ formatMessage(messages.exportButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="exportModal.hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" @click="exportPack">
<PackageIcon />
{{ formatMessage(messages.exportButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -7,7 +7,7 @@ import {
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
@@ -168,30 +168,37 @@ onUnmounted(() => unlisten())
<span class="line-clamp-2">{{ instance.name }}</span>
</div>
<div class="flex items-center">
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="'Instance is loading...'" disabled>
<SpinnerIcon class="animate-spin" />
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="!instance.quarantined"
:color="first ? 'brand' : 'standard'"
circular
<IconButton
v-if="playing"
v-tooltip="'Stop'"
type="colored"
color="red"
:label="'Stop'"
@mouseenter="checkProcess"
@click="(e) => stop(e, 'InstanceCard')"
>
<button
v-tooltip="'Play'"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" />
</button>
</ButtonStyled>
<StopCircleIcon />
</IconButton>
<IconButton
v-else-if="modLoading"
v-tooltip="'Instance is loading...'"
:label="'Instance is loading...'"
disabled
>
<SpinnerIcon class="animate-spin" />
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="'Play'"
:type="first ? 'colored' : 'base'"
:color="first ? 'brand' : undefined"
label="Play"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" />
</IconButton>
</div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon />
@@ -219,47 +226,51 @@ onUnmounted(() => unlisten())
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="playing" size="large" color="red" circular>
<button
v-tooltip="'Stop'"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<IconButton
v-if="playing"
v-tooltip="'Stop'"
type="colored"
color="red"
size="xl"
:label="'Stop'"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<StopCircleIcon />
</IconButton>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled
<IconButton
v-else-if="!installed && !instance.quarantined"
size="large"
v-tooltip="'Repair'"
type="colored"
color="brand"
circular
size="xl"
:label="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
<button
v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="!instance.quarantined" size="large" color="brand" circular>
<button
v-tooltip="'Play'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<PlayIcon class="translate-x-[2px]" />
</button>
</ButtonStyled>
<DownloadIcon />
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="'Play'"
type="colored"
color="brand"
size="xl"
:label="'Play'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<PlayIcon class="translate-x-[2px]" />
</IconButton>
</div>
</div>
<div class="flex flex-col gap-1">
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, FormattedTag } from '@modrinth/ui'
import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed } from 'vue'
@@ -48,9 +48,7 @@ const instanceLink = computed(() => {
</span>
</span>
</router-link>
<ButtonStyled>
<router-link :to="instanceLink"> <LeftArrowIcon /> Back to instance </router-link>
</ButtonStyled>
<ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </ButtonLink>
</div>
</template>
@@ -1,5 +1,9 @@
<template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<ModalWrapper
ref="detectJavaModal"
:header="formatMessage(messages.title)"
:show-ad-on-close="false"
>
<div class="flex flex-col gap-4">
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
<template #cell-version="{ value }">
@@ -10,51 +14,95 @@
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end">
<ButtonStyled v-if="currentSelected.path === row.path">
<button class="!shadow-none" disabled><CheckIcon /> Selected</button>
</ButtonStyled>
<ButtonStyled v-else>
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
</ButtonStyled>
<Button v-if="currentSelected.path === row.path" disabled>
<CheckIcon aria-hidden="true" />
{{ formatMessage(messages.selected) }}
</Button>
<Button v-else @click="setJavaInstall(row)">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.select) }}
</Button>
</div>
</template>
<template #empty-state>
<div class="p-4 text-secondary">No java installations found!</div>
<div class="p-4 text-secondary">
{{ formatMessage(messages.noInstallationsFound) }}
</div>
</template>
</Table>
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button
class="!shadow-none !border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon />
Cancel
</button>
</ButtonStyled>
<Button
type="outlined"
class="!border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancel) }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
import { ref } from 'vue'
import { Button, defineMessages, injectNotificationManager, Table, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { find_filtered_jres } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.java-detection.title',
defaultMessage: 'Select Java installation',
},
versionColumn: {
id: 'app.java-detection.columns.version',
defaultMessage: 'Version',
},
pathColumn: {
id: 'app.java-detection.columns.path',
defaultMessage: 'Path',
},
actionsColumn: {
id: 'app.java-detection.columns.actions',
defaultMessage: 'Actions',
},
selected: {
id: 'app.java-detection.selected',
defaultMessage: 'Selected',
},
select: {
id: 'app.java-detection.select',
defaultMessage: 'Select',
},
noInstallationsFound: {
id: 'app.java-detection.no-installations-found',
defaultMessage: 'No Java installations found.',
},
cancel: {
id: 'app.java-detection.cancel',
defaultMessage: 'Cancel',
},
})
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)
const currentSelected = ref({})
const javaInstallColumns = [
{ key: 'version', label: 'Version', width: '9rem' },
{ key: 'path', label: 'Path' },
{ key: 'actions', label: 'Actions', align: 'right', width: '10rem' },
]
const javaInstallColumns = computed(() => [
{ key: 'version', label: formatMessage(messages.versionColumn), width: '9rem' },
{ key: 'path', label: formatMessage(messages.pathColumn) },
{
key: 'actions',
label: formatMessage(messages.actionsColumn),
align: 'right',
width: '10rem',
},
])
defineExpose({
show: async (version, currentSelectedJava) => {
@@ -6,7 +6,7 @@
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
:placeholder="placeholder ?? formatMessage(messages.pathPlaceholder)"
wrapper-class="installation-input"
@update:model-value="
(val) => {
@@ -17,57 +17,73 @@
}
"
/>
<ButtonStyled
<Button
type="quiet"
:color="
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
: undefined
"
color-fill="text"
:aria-label="formatMessage(messages.testJavaInstallation)"
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
:disabled="testingJava || props.disabled"
:style="{
'--legacy-button-color':
(!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard') &&
(!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard') !== 'standard'
? `var(--color-${
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
})`
: undefined,
}"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<button
class="!shadow-none"
:disabled="testingJava || props.disabled"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="testingJavaSuccess === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</button>
</ButtonStyled>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon v-else-if="testingJavaSuccess === true && !hoveringTest" class="h-4 w-4" />
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</Button>
</div>
<span class="installation-buttons">
<ButtonStyled v-if="props.version">
<button
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
class="!shadow-none"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
<Button
v-if="props.version"
v-tooltip="
testingJavaSuccess === true ? formatMessage(messages.alreadyInstalled) : undefined
"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<DownloadIcon />
{{
installingJava
? formatMessage(messages.installing)
: formatMessage(messages.installRecommended)
}}
</Button>
<Button :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
{{ formatMessage(messages.detect) }}
</Button>
<Button :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
{{ formatMessage(messages.browse) }}
</Button>
</span>
</div>
</template>
@@ -82,7 +98,13 @@ import {
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import {
Button,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -92,6 +114,38 @@ import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
pathPlaceholder: {
id: 'app.java-selector.path.placeholder',
defaultMessage: '/path/to/java',
},
testJavaInstallation: {
id: 'app.java-selector.test-installation',
defaultMessage: 'Test Java installation',
},
alreadyInstalled: {
id: 'app.java-selector.already-installed',
defaultMessage: 'Already installed',
},
installing: {
id: 'app.java-selector.installing',
defaultMessage: 'Installing...',
},
installRecommended: {
id: 'app.java-selector.install-recommended',
defaultMessage: 'Install recommended',
},
detect: {
id: 'app.java-selector.detect',
defaultMessage: 'Detect',
},
browse: {
id: 'app.java-selector.browse',
defaultMessage: 'Browse',
},
})
const props = defineProps({
id: {
@@ -1,6 +1,6 @@
<script setup>
import { CheckIcon } from '@modrinth/assets'
import { Badge, ButtonStyled } from '@modrinth/ui'
import { Badge, IconButton } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js'
@@ -74,18 +74,16 @@ const onHide = () => {
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<ButtonStyled
circular
:color="version.id === installedVersion ? 'standard' : 'brand'"
<IconButton
:type="version.id === installedVersion ? 'base' : 'colored'"
:color="version.id === installedVersion ? undefined : 'brand'"
label="Switch version"
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
>
<button
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</button>
</ButtonStyled>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</IconButton>
</div>
<div class="name-cell table-cell table-text">
<div class="version-link">
@@ -1,41 +1,151 @@
<script setup>
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, injectNotificationManager } from '@modrinth/ui'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
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 STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const maxAuto = ref(0)
const allInstances = ref([])
const dragging = ref(false)
const stored = localStorage.getItem(STORAGE_KEY)
const userLimit = ref(stored === null ? null : Number(stored))
const maxVisible = computed(() => Math.min(maxAuto.value, allInstances.value.length))
const visibleCount = computed(() => Math.min(userLimit.value ?? maxVisible.value, maxVisible.value))
const recentInstances = computed(() => allInstances.value.slice(0, visibleCount.value))
const canDrag = computed(() => maxVisible.value > 0)
const showOverdrag = ref(false)
const updateMaxAuto = () => {
maxAuto.value = Math.max(
0,
Math.floor((window.innerHeight - APPROX_USED_VERTICAL_SPACE) / ITEM_SIZE),
)
}
const setLimit = (count) => {
const clamped = Math.max(0, Math.min(count, maxVisible.value))
if (clamped >= maxVisible.value) {
userLimit.value = null
localStorage.removeItem(STORAGE_KEY)
} else {
userLimit.value = clamped
localStorage.setItem(STORAGE_KEY, String(clamped))
}
}
let dragStartY = 0
let dragStartCount = 0
let wasOverdragging = false
let overdragTimeout = null
const clearOverdragFlash = () => {
showOverdrag.value = false
if (overdragTimeout !== null) {
clearTimeout(overdragTimeout)
overdragTimeout = null
}
}
const flashOverdrag = () => {
showOverdrag.value = true
if (overdragTimeout !== null) {
clearTimeout(overdragTimeout)
}
overdragTimeout = setTimeout(() => {
showOverdrag.value = false
overdragTimeout = null
}, 500)
}
const onDividerPointerDown = (event) => {
if (!canDrag.value) {
return
}
event.preventDefault()
dragging.value = true
wasOverdragging = false
clearOverdragFlash()
dragStartY = event.clientY
dragStartCount = visibleCount.value
document.body.classList.add('quick-instance-dragging')
event.currentTarget.setPointerCapture(event.pointerId)
}
const onDividerPointerMove = (event) => {
if (!dragging.value) {
return
}
const delta = event.clientY - dragStartY
const target = dragStartCount + Math.round(delta / ITEM_SIZE)
const isOverdragging = target < 0 || target > maxAuto.value
if (isOverdragging && !wasOverdragging) {
flashOverdrag()
}
wasOverdragging = isOverdragging
setLimit(target)
}
const endDrag = (event) => {
if (!dragging.value) {
return
}
dragging.value = false
wasOverdragging = false
clearOverdragFlash()
document.body.classList.remove('quick-instance-dragging')
if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
const onDividerPointerUp = (event) => {
endDrag(event)
}
const recentInstances = ref([])
const getInstances = async () => {
const instances = await list().catch(handleError)
recentInstances.value = instances
.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
for (const instance of instances) {
queryClient.setQueryData(['instances', 'summary', instance.id], instance)
}
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
allInstances.value = instances.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
return dateB - dateA
})
.slice(0, 3)
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
}
await getInstances()
updateMaxAuto()
const unlistenInstance = await instance_listener(async (event) => {
if (event.event !== 'synced') {
@@ -43,29 +153,157 @@ const unlistenInstance = await instance_listener(async (event) => {
}
})
onMounted(() => {
window.addEventListener('resize', updateMaxAuto)
})
onUnmounted(() => {
window.removeEventListener('resize', updateMaxAuto)
document.body.classList.remove('quick-instance-dragging')
clearOverdragFlash()
unlistenInstance()
})
const messages = defineMessages({
dragTooltip: {
id: 'app.quick-instance-switcher.drag-tooltip',
defaultMessage: 'Drag to resize',
},
dragShowTooltip: {
id: 'app.quick-instance-switcher.drag-show-tooltip',
defaultMessage: 'Drag to show recent instances',
},
})
const dividerTooltip = computed(() => {
if (!canDrag.value || dragging.value) {
return null
}
return formatMessage(visibleCount.value === 0 ? messages.dragShowTooltip : messages.dragTooltip)
})
</script>
<template>
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<Transition name="top-divider">
<div
v-if="recentInstances.length > 0"
class="top-divider flex items-center justify-center overflow-hidden"
>
<div class="h-px w-8 bg-surface-5 shrink-0"></div>
</div>
</Transition>
<TransitionGroup name="quick-instance" tag="div" class="flex flex-col items-center">
<div
v-for="instance in recentInstances"
:key="instance.id"
v-tooltip.right="instance.name"
class="quick-instance-item"
>
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
</div>
</TransitionGroup>
<div
v-tooltip.right="dividerTooltip"
class="flex items-center justify-center py-2 select-none"
:class="canDrag ? 'cursor-ns-resize touch-none group' : ''"
@pointerdown="onDividerPointerDown"
@pointermove="onDividerPointerMove"
@pointerup="onDividerPointerUp"
@pointercancel="onDividerPointerUp"
>
<div
class="h-px w-8 transition-colors duration-200"
:class="
showOverdrag ? 'bg-red' : canDrag ? 'bg-surface-5 group-hover:bg-secondary' : 'bg-surface-5'
"
></div>
</div>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.top-divider {
height: calc(1rem + 1px);
}
.top-divider-enter-active,
.top-divider-leave-active {
transition:
opacity 0.25s ease,
height 0.25s ease;
}
.top-divider-enter-from,
.top-divider-leave-to {
opacity: 0;
height: 0;
}
.quick-instance-item {
height: 3rem;
overflow: hidden;
& + & {
margin-top: 0.25rem;
}
}
.quick-instance-enter-active,
.quick-instance-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s ease,
height 0.25s ease,
margin-top 0.25s ease;
}
.quick-instance-enter-from,
.quick-instance-leave-to {
opacity: 0;
transform: scale(0.5);
height: 0;
margin-top: 0 !important;
}
@media (prefers-reduced-motion: reduce) {
.top-divider-enter-active,
.top-divider-leave-active,
.quick-instance-enter-active,
.quick-instance-leave-active {
transition: none;
}
.top-divider-enter-from,
.top-divider-leave-to {
opacity: 1;
height: calc(1rem + 1px);
}
.quick-instance-enter-from,
.quick-instance-leave-to {
opacity: 1;
transform: none;
height: 3rem;
margin-top: unset !important;
}
}
</style>
<style lang="scss">
body.quick-instance-dragging,
body.quick-instance-dragging * {
cursor: ns-resize !important;
}
</style>
@@ -0,0 +1,229 @@
<script setup lang="ts">
import { NotepadTextIcon, XIcon } from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { type } from '@tauri-apps/plugin-os'
import { $fetch } from 'ofetch'
import { onMounted, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { list } from '@/helpers/instance'
import { get as getCreds } from '@/helpers/mr_auth.ts'
type Survey = {
id: string
tally_id: string
type: string
condition?: string
assigned_users?: string[]
dismissed_users?: string[]
}
type TallyApi = {
openPopup: (formId: string, options: object) => void
}
const tallyWindow = window as Window & { Tally?: TallyApi }
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const availableSurvey = ref<Survey | null>(null)
const messages = defineMessages({
surveyTitle: {
id: 'app.survey.title',
defaultMessage: 'Hey there Modrinth user!',
},
surveyBody: {
id: 'app.survey.body',
defaultMessage:
'Would you mind answering a few questions about your experience with Modrinth App?',
},
surveyFooter: {
id: 'app.survey.footer',
defaultMessage:
'This feedback will go directly to the Modrinth team and help guide future updates!',
},
takeSurvey: {
id: 'app.survey.take-survey',
defaultMessage: 'Take survey',
},
surveyNoThanks: {
id: 'app.survey.no-thanks',
defaultMessage: 'No thanks',
},
})
function cleanupOldSurveyDisplayData() {
const threeWeeksAgo = new Date()
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21)
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith('survey-') && key.endsWith('-display')) {
const dateValue = new Date(localStorage.getItem(key) ?? '')
if (dateValue < threeWeeksAgo) {
localStorage.removeItem(key)
}
}
}
}
async function openSurvey() {
if (!availableSurvey.value) {
console.error('No survey to open')
return
}
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const formId = availableSurvey.value.tally_id
const popupOptions = {
layout: 'modal',
width: 700,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
user_id: userId,
},
onOpen: () => console.info('Opened user survey'),
onClose: () => {
console.info('Closed user survey')
show_ads_window()
},
onSubmit: () => console.info('Active user survey submitted'),
}
try {
hide_ads_window()
if (tallyWindow.Tally?.openPopup) {
console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
dismissSurvey()
tallyWindow.Tally.openPopup(formId, popupOptions)
} else {
console.warn('Tally script not yet loaded')
show_ads_window()
}
} catch (e) {
console.error('Error opening Tally popup:', e)
show_ads_window()
}
console.info(`Found user survey to show with tally_id: ${formId}`)
tallyWindow.Tally?.openPopup(formId, popupOptions)
}
function dismissSurvey() {
if (!availableSurvey.value) return
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, String(new Date()))
availableSurvey.value = null
}
async function processPendingSurveys() {
function isWithinLastTwoWeeks(date: string | Date | null | undefined) {
if (!date) return false
const twoWeeksAgo = new Date()
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
return new Date(date) >= twoWeeksAgo
}
cleanupOldSurveyDisplayData()
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const instances = (await list().catch(handleError)) ?? []
const isActivePlayer = instances.some(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
let surveys: Survey[] = []
try {
surveys = await $fetch('https://api.modrinth.com/v2/surveys')
} catch (e) {
console.error('Error fetching surveys:', e)
}
const surveyToShow = surveys.find(
(survey) =>
!!(
localStorage.getItem(`survey-${survey.id}-display`) === null &&
survey.type === 'tally_app' &&
((survey.condition === 'active_player' && isActivePlayer) ||
(!!userId &&
survey.assigned_users?.includes(userId) &&
!survey.dismissed_users?.includes(userId)))
),
)
if (surveyToShow) {
availableSurvey.value = surveyToShow
} else {
console.info('No user survey to show')
}
}
onMounted(async () => {
const osType = await type()
if (osType === 'windows') {
await processPendingSurveys()
} else {
console.info('Skipping user surveys on non-Windows platforms')
}
})
</script>
<template>
<transition name="popup-survey">
<div
v-if="availableSurvey"
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
>
<h2 class="text-lg font-extrabold mt-0 mb-2">
{{ formatMessage(messages.surveyTitle) }}
</h2>
<p class="m-0 leading-tight">
{{ formatMessage(messages.surveyBody) }}
</p>
<p class="mt-3 mb-4 leading-tight">
{{ formatMessage(messages.surveyFooter) }}
</p>
<div class="flex gap-2">
<Button type="colored" color="brand" @click="openSurvey">
<NotepadTextIcon />
{{ formatMessage(messages.takeSurvey) }}
</Button>
<Button @click="dismissSurvey">
<XIcon />
{{ formatMessage(messages.surveyNoThanks) }}
</Button>
</div>
</div>
</transition>
</template>
<style scoped>
.popup-survey-enter-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.51, 1.08, 0.35, 1.15);
transform-origin: top center;
}
.popup-survey-leave-active {
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.68, -0.17, 0.23, 0.11);
transform-origin: top center;
}
.popup-survey-enter-from,
.popup-survey-leave-to {
opacity: 0;
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { ButtonStyled, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import { Button, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -69,9 +69,7 @@ async function install() {
</p>
</div>
<div class="button-group">
<ButtonStyled color="brand">
<button @click="install">Install</button>
</ButtonStyled>
<Button type="colored" color="brand" @click="install">Install</Button>
</div>
</div>
</div>
@@ -4,34 +4,38 @@
class="flex items-center gap-2 mr-1.5"
data-tauri-drag-region-exclude
>
<ButtonStyled type="transparent" circular>
<button class="relative expanded-button" @click="() => getCurrentWindow().minimize()">
<MinimizeIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<button class="relative expanded-button" @click="() => getCurrentWindow().toggleMaximize()">
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled
type="transparent"
color="red"
color-fill="none"
hover-color-fill="background"
circular
<IconButton
type="quiet"
label="Minimize window"
class="relative expanded-button"
@click="() => getCurrentWindow().minimize()"
>
<button class="relative expanded-button close-button" @click="handleClose">
<XIcon />
</button>
</ButtonStyled>
<MinimizeIcon />
</IconButton>
<IconButton
type="quiet"
label="Toggle maximize window"
class="relative expanded-button"
@click="() => getCurrentWindow().toggleMaximize()"
>
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</IconButton>
<IconButton
type="quiet"
color="red"
label="Close window"
class="relative expanded-button close-button hover:!bg-red focus-visible:!bg-red"
@click="handleClose"
>
<XIcon />
</IconButton>
</section>
</template>
<script setup>
import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { IconButton } from '@modrinth/ui'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue'
@@ -1,31 +1,30 @@
<template>
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
<button
v-if="showUpdatePill"
type="button"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out"
:class="{
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
}"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span>
<span v-else>{{ updateLabel }}</span>
</button>
</ButtonStyled>
<Button
v-if="showUpdatePill"
type="outlined"
native-type="button"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out !text-brand [&>svg]:!text-brand !shadow-[inset_0_0_0_1px_var(--color-brand)] hover:!bg-brand focus-visible:!bg-brand hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
:class="{
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
}"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span>
<span v-else>{{ updateLabel }}</span>
</Button>
</template>
<script setup lang="ts">
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
@@ -2,8 +2,9 @@
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Button,
defineMessages,
IconButton,
injectNotificationManager,
IntlFormatted,
StyledInput,
@@ -197,26 +198,20 @@ const messages = defineMessages({
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials?.user_id">
<ButtonStyled color="brand">
<button @click="addFriend(friend)">
<UserPlusIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Ignore
</button>
</ButtonStyled>
<Button type="colored" color="brand" @click="addFriend(friend)">
<UserPlusIcon />
Accept
</Button>
<Button @click="removeFriend(friend)">
<XIcon />
Ignore
</Button>
</template>
<template v-else>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Cancel
</button>
</ButtonStyled>
<Button @click="removeFriend(friend)">
<XIcon />
Cancel
</Button>
</template>
</div>
</div>
@@ -240,26 +235,28 @@ const messages = defineMessages({
wrapper-class="flex-1"
@keyup.enter="addFriendFromModal"
/>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<SendIcon />
{{ formatMessage(messages.sendFriendRequest) }}
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:disabled="username.length === 0"
@click="addFriendFromModal"
>
<SendIcon />
{{ formatMessage(messages.sendFriendRequest) }}
</Button>
</div>
</div>
</ModalWrapper>
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 -ml-1">
<template v-if="sortedFriends.length > 0">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.addFriend)"
:aria-label="formatMessage(messages.addFriend)"
@click="addFriendModal.show"
>
<UserPlusIcon />
</button>
</ButtonStyled>
<IconButton
v-tooltip="formatMessage(messages.addFriend)"
type="quiet"
:label="formatMessage(messages.addFriend)"
@click="addFriendModal.show"
>
<UserPlusIcon />
</IconButton>
<StyledInput
v-model="search"
:icon="SearchIcon"
@@ -274,23 +271,23 @@ const messages = defineMessages({
<h3 v-else class="w-full text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
<button
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
class="relative"
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
@click="friendInvitesModal.show"
<IconButton
v-if="incomingRequests.length > 0"
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
type="quiet"
:label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
class="relative"
@click="friendInvitesModal.show"
>
<MailIcon />
<span
v-if="incomingRequests.length > 0"
aria-hidden="true"
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
>
<MailIcon />
<span
v-if="incomingRequests.length > 0"
aria-hidden="true"
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
>
{{ incomingRequests.length }}
</span>
</button>
</ButtonStyled>
{{ incomingRequests.length }}
</span>
</IconButton>
</div>
<div class="flex flex-col gap-3">
<h3 v-if="loading" class="text-base text-primary font-medium m-0">
@@ -3,18 +3,19 @@ import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
Avatar,
ButtonStyled,
defineMessages,
OverflowMenu,
IconButton,
TeleportOverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
const router = useRouter()
const props = withDefaults(
defineProps<{
@@ -54,7 +55,7 @@ function createContextMenuOptions(friend: FriendWithUserData) {
}
function openProfile(username: string) {
openUrl('https://modrinth.com/user/' + username)
void router.push(`/user/${encodeURIComponent(username)}`)
}
const friendOptions = useTemplateRef('friendOptions')
@@ -122,68 +123,71 @@ const messages = defineMessages({
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
class="group grid items-center grid-cols-[1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
@contextmenu.prevent.stop="
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
<ButtonStyled v-if="friend.accepted" circular type="transparent">
<OverflowMenu
class="opacity-0 group-hover:opacity-100 transition-opacity"
:options="[
{
id: 'view-profile',
action: () => openProfile(friend.username),
},
{
id: 'remove-friend',
action: () => removeFriend(friend),
color: 'red',
},
]"
>
<MoreVerticalIcon />
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend>
<TrashIcon />
{{ formatMessage(messages.removeFriend) }}
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-else type="transparent" circular>
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)">
<XIcon />
</button>
</ButtonStyled>
<RouterLink
:to="`/user/${encodeURIComponent(friend.username)}`"
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
</RouterLink>
<TeleportOverflowMenu
v-if="friend.accepted"
type="quiet"
label="More options"
class="opacity-0 group-hover:opacity-100 transition-opacity"
:options="[
{
id: 'remove-friend',
label: formatMessage(messages.removeFriend),
action: () => removeFriend(friend),
tone: 'red',
},
]"
>
<MoreVerticalIcon />
<template #remove-friend>
<TrashIcon />
{{ formatMessage(messages.removeFriend) }}
</template>
</TeleportOverflowMenu>
<IconButton
v-else
v-tooltip="formatMessage(messages.cancelRequest)"
type="quiet"
:label="formatMessage(messages.cancelRequest)"
@click="removeFriend(friend)"
>
<XIcon />
</IconButton>
</div>
</div>
</template>
@@ -1,12 +1,6 @@
<script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
injectNotificationManager,
StyledInput,
} from '@modrinth/ui'
import { Admonition, Avatar, Button, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
@@ -109,19 +103,15 @@ async function addServer(instance) {
/>
{{ instance.name }}
</router-link>
<ButtonStyled>
<button :disabled="instance.added || instance.adding" @click="addServer(instance)">
<PlusIcon v-if="!instance.added && !instance.adding" />
<CheckIcon v-else-if="instance.added" />
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
</button>
</ButtonStyled>
<Button :disabled="instance.added || instance.adding" @click="addServer(instance)">
<PlusIcon v-if="!instance.added && !instance.adding" />
<CheckIcon v-else-if="instance.added" />
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
</Button>
</div>
</div>
<div class="input-group push-right">
<ButtonStyled>
<button @click="modal.hide()">Cancel</button>
</ButtonStyled>
<Button @click="modal.hide()">Cancel</Button>
</div>
</div>
</ModalWrapper>
@@ -15,12 +15,12 @@
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-else
:icon="UnknownIcon"
:tooltip="sharedInstanceTooltip"
aria-label="Shared instance information"
class="!border-blue !bg-highlight-blue !text-blue"
>
Shared
<UnknownIcon class="block size-4 shrink-0 text-current" aria-hidden="true" />
</PageHeaderBadgeItem>
</template>
@@ -85,66 +85,98 @@
<template #actions>
<PageHeaderActions>
<ButtonStyled v-if="isInstalling" color="brand" size="large">
<button type="button" disabled>
{{ formatMessage(commonMessages.installingLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing" color="red" size="large">
<button type="button" :disabled="stopping" @click="emit('stop')">
<StopCircleIcon />
{{
stopping ? formatMessage(messages.stopping) : formatMessage(commonMessages.stopButton)
}}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.quarantined" color="brand" size="large">
<button v-tooltip="formatMessage(messages.lockedPlayTooltip)" type="button" disabled>
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button type="button" @click="emit('repair')">
<DownloadIcon />
{{ formatMessage(messages.repair) }}
</button>
</ButtonStyled>
<JoinedButtons
v-else-if="!loading && isServerInstance"
:actions="serverPlayActions"
<Button
v-if="isInstalling"
type="colored"
color="brand"
size="large"
/>
<ButtonStyled v-else-if="!loading" color="brand" size="large">
<button type="button" @click="emit('play')">
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button type="button" disabled>{{ formatMessage(messages.starting) }}</button>
</ButtonStyled>
size="xl"
native-type="button"
disabled
>
{{ formatMessage(commonMessages.installingLabel) }}
</Button>
<Button
v-else-if="playing"
type="colored"
color="red"
size="xl"
native-type="button"
:disabled="stopping"
@click="emit('stop')"
>
<StopCircleIcon />
{{
stopping ? formatMessage(messages.stopping) : formatMessage(commonMessages.stopButton)
}}
</Button>
<Button
v-else-if="instance.quarantined"
v-tooltip="formatMessage(messages.lockedPlayTooltip)"
type="colored"
color="brand"
size="xl"
native-type="button"
disabled
>
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<Button
v-else-if="instance.install_stage !== 'installed'"
type="colored"
color="brand"
size="xl"
native-type="button"
@click="emit('repair')"
>
<DownloadIcon />
{{ formatMessage(messages.repair) }}
</Button>
<SplitButton
v-else-if="!loading && isServerInstance"
type="colored"
color="brand"
size="xl"
:options="serverPlayOptions"
:menu-label="formatMessage(messages.launchInstance)"
@click="emit('playServer')"
>
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</SplitButton>
<Button
v-else-if="!loading"
type="colored"
color="brand"
size="xl"
native-type="button"
@click="emit('play')"
>
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<Button v-else type="colored" color="brand" size="xl" native-type="button" disabled>{{
formatMessage(messages.starting)
}}</Button>
<ButtonStyled circular size="large">
<button
v-tooltip="formatMessage(messages.instanceSettings)"
type="button"
:aria-label="formatMessage(messages.instanceSettings)"
@click="emit('settings')"
>
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="moreActions"
:tooltip="formatMessage(messages.moreActions)"
:aria-label="formatMessage(messages.moreActions)"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
<IconButton
v-tooltip="formatMessage(messages.instanceSettings)"
size="xl"
:label="formatMessage(messages.instanceSettings)"
native-type="button"
@click="emit('settings')"
>
<SettingsIcon />
</IconButton>
<TeleportOverflowMenu
type="quiet"
size="xl"
:label="formatMessage(messages.moreActions)"
:tooltip="formatMessage(messages.moreActions)"
:options="moreActions"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</PageHeaderActions>
</template>
</PageHeader>
@@ -167,27 +199,24 @@ import {
TimerIcon,
UnknownIcon,
} from '@modrinth/assets'
import { Button, IconButton, SplitButton, TeleportOverflowMenu } from '@modrinth/ui'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
formatLoaderLabel,
type JoinedButtonAction,
JoinedButtons,
LoaderIcon as ServerLoaderIcon,
type OverflowMenuOption,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
PageHeaderMetadata,
PageHeaderMetadataItem,
type ServerLoader,
TeleportOverflowMenu,
type TeleportOverflowMenuItem,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
@@ -247,6 +276,7 @@ const messages = defineMessages({
defaultMessage: "This instance's content is being shared to other users.",
},
})
const router = useRouter()
const props = withDefaults(
defineProps<{
@@ -331,7 +361,7 @@ const sharedInstanceManagerLabel = computed(() =>
const sharedInstanceManagerAction = computed(() => {
const manager = props.sharedInstanceManager
if (manager?.type !== 'user') return undefined
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(manager.name)}`)
return () => router.push(`/user/${encodeURIComponent(manager.name)}`)
})
const playtimeLabel = computed(() => {
if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed)
@@ -349,13 +379,7 @@ const playtimeLabel = computed(() => {
const seconds = Math.floor(props.timePlayed)
return `${seconds} second${seconds > 1 ? 's' : ''}`
})
const serverPlayActions = computed<JoinedButtonAction[]>(() => [
{
id: 'join_server',
label: formatMessage(commonMessages.playButton),
icon: PlayIcon,
action: () => emit('playServer'),
},
const serverPlayOptions = computed<OverflowMenuOption[]>(() => [
{
id: 'launch_instance',
label: formatMessage(messages.launchInstance),
@@ -363,8 +387,8 @@ const serverPlayActions = computed<JoinedButtonAction[]>(() => [
action: () => emit('play'),
},
])
const moreActions = computed<TeleportOverflowMenuItem[]>(() => {
const actions: TeleportOverflowMenuItem[] = [
const moreActions = computed<OverflowMenuOption[]>(() => {
const actions: OverflowMenuOption[] = [
{
id: 'open-folder',
label: formatMessage(messages.openFolder),
@@ -392,12 +416,12 @@ const moreActions = computed<TeleportOverflowMenuItem[]>(() => {
if (props.instance.shared_instance?.role === 'member') {
actions.push(
{ divider: true },
{ type: 'divider' },
{
id: 'report-shared-instance',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
color: 'red',
tone: 'red',
action: (event) => emit('report', event),
},
)
@@ -6,6 +6,11 @@
:instance="instance"
@published="emit('published')"
/>
<InstanceAdmonitionsSharedInstanceUpdateAvailable
v-else-if="item.kind === 'shared-instance-update-available'"
:instance-name="instance.name"
@review="emit('review-update', $event)"
/>
<InstanceAdmonitionsSharedInstanceWrongAccount
v-else-if="item.kind === 'shared-instance-wrong-account'"
:expected-user-id="sharedInstanceExpectedUserId"
@@ -33,6 +38,7 @@ import type { GameInstance } from '@/helpers/types'
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceUpdateAvailable from './instance-admonitions-shared-instance-update-available.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
@@ -48,11 +54,13 @@ const props = defineProps<{
sharedInstanceExpectedUserId?: string | null
sharedInstanceRole?: SharedInstanceRole | null
sharedInstanceSignedOut?: boolean
sharedInstanceUpdateAvailable?: boolean
}>()
const emit = defineEmits<{
published: []
delete: []
'review-update': [event: MouseEvent]
}>()
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
@@ -68,6 +76,14 @@ const showSharedInstancePublishAdmonition = computed(
props.instance.shared_instance?.role === 'owner' &&
props.instance.shared_instance.status === 'stale',
)
const showSharedInstanceUpdateAdmonition = computed(
() =>
!sharedInstanceWrongAccount.value &&
!displayedSharedInstanceUnavailableReason.value &&
props.instance.install_stage === 'installed' &&
props.sharedInstanceRole === 'member' &&
props.sharedInstanceUpdateAvailable === true,
)
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
const items: InstanceAdmonitionItem[] = []
@@ -104,6 +120,15 @@ const stackItems = computed<InstanceAdmonitionItem[]>(() => {
})
}
if (showSharedInstanceUpdateAdmonition.value) {
items.push({
id: 'shared-instance-update-available',
type: 'info',
dismissible: false,
kind: 'shared-instance-update-available',
})
}
return items
})
@@ -21,6 +21,19 @@ export const instanceAdmonitionsMessages = defineMessages({
id: 'app.instance.admonitions.shared-instance.reviewing-button',
defaultMessage: 'Reviewing...',
},
sharedInstanceUpdateAvailableHeader: {
id: 'app.instance.admonitions.shared-instance.update-available-header',
defaultMessage: 'An update is available',
},
sharedInstanceUpdateAvailableBody: {
id: 'app.instance.admonitions.shared-instance.update-available-body',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
sharedInstanceReviewUpdateButton: {
id: 'app.instance.admonitions.shared-instance.review-update-button',
defaultMessage: 'Review update',
},
sharedInstanceReviewHeader: {
id: 'app.instance.admonitions.shared-instance.review-header',
defaultMessage: 'Review changes',
@@ -6,23 +6,27 @@
>
{{ formatMessage(messages.sharedInstanceChangesBody) }}
<template #actions>
<ButtonStyled color="blue">
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
<SpinnerIcon
v-if="isReviewingPublish || isPublishing"
class="animate-spin"
aria-hidden="true"
/>
<UploadIcon v-else aria-hidden="true" />
{{
isPublishing
? formatMessage(messages.sharedInstancePublishingButton)
: isReviewingPublish
? formatMessage(messages.sharedInstanceReviewingButton)
: formatMessage(messages.sharedInstancePublishButton)
}}
</button>
</ButtonStyled>
<Button
type="colored"
color="blue"
size="lg"
:disabled="isPublishButtonDisabled"
@click="reviewChanges"
>
<SpinnerIcon
v-if="isReviewingPublish || isPublishing"
class="animate-spin"
aria-hidden="true"
/>
<UploadIcon v-else aria-hidden="true" />
{{
isPublishing
? formatMessage(messages.sharedInstancePublishingButton)
: isReviewingPublish
? formatMessage(messages.sharedInstanceReviewingButton)
: formatMessage(messages.sharedInstancePublishButton)
}}
</Button>
</template>
</Admonition>
@@ -36,7 +40,7 @@
<script setup lang="ts">
import { SpinnerIcon, UploadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { Admonition, Button, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
@@ -8,19 +8,17 @@
>
{{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
<template v-if="reason === 'quarantined'" #actions>
<ButtonStyled color="orange">
<button class="!h-10" @click="emit('delete')">
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.deleteInstance) }}
</button>
</ButtonStyled>
<Button type="colored" color="orange" size="lg" @click="emit('delete')">
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.deleteInstance) }}
</Button>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { TrashIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { Admonition, Button, defineMessages, useVIntl } from '@modrinth/ui'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import {
@@ -0,0 +1,32 @@
<template>
<Admonition
type="info"
inline-actions
:header="formatMessage(messages.sharedInstanceUpdateAvailableHeader)"
>
{{ formatMessage(messages.sharedInstanceUpdateAvailableBody, { name: instanceName }) }}
<template #actions>
<Button type="colored" color="blue" size="lg" @click="emit('review', $event)">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.sharedInstanceReviewUpdateButton) }}
</Button>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import { Admonition, Button, useVIntl } from '@modrinth/ui'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
defineProps<{
instanceName: string
}>()
const emit = defineEmits<{
review: [event: MouseEvent]
}>()
const { formatMessage } = useVIntl()
</script>
@@ -2,6 +2,7 @@ import type { StackedAdmonitionItem } from '@modrinth/ui'
export type InstanceAdmonitionKind =
| 'shared-instance-stale'
| 'shared-instance-update-available'
| 'shared-instance-unavailable'
| 'shared-instance-wrong-account'
@@ -2,13 +2,13 @@
import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Button,
Checkbox,
Chips,
defineMessages,
injectNotificationManager,
OverflowMenu,
StyledInput,
TeleportOverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@@ -109,9 +109,13 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
})
async function resetIcon() {
icon.value = undefined
await edit_icon(instance.value.id, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
try {
await edit_icon(instance.value.id, null)
icon.value = undefined
trackEvent('InstanceRemoveIcon')
} catch (error) {
handleError(error)
}
}
async function setIcon() {
@@ -127,10 +131,13 @@ async function setIcon() {
if (!value) return
icon.value = value
await edit_icon(instance.value.id, icon.value).catch(handleError)
trackEvent('InstanceSetIcon')
try {
await edit_icon(instance.value.id, value)
icon.value = value
trackEvent('InstanceSetIcon')
} catch (error) {
handleError(error)
}
}
const editInstanceObject = computed(() => ({
@@ -291,17 +298,24 @@ const messages = defineMessages({
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Icon</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
<TeleportOverflowMenu
:label="formatMessage(messages.editIcon)"
:tooltip="formatMessage(messages.editIcon)"
:icon-only="false"
type="quiet"
interaction="none"
class="m-0 !h-auto cursor-pointer appearance-none border-none bg-transparent !p-0 transition-transform group-active:scale-95"
:options="[
{
id: 'select',
label: icon
? formatMessage(messages.replaceIcon)
: formatMessage(messages.selectIcon),
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
label: formatMessage(messages.removeIcon),
action: () => resetIcon(),
shown: !!icon,
},
@@ -324,7 +338,7 @@ const messages = defineMessages({
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</TeleportOverflowMenu>
</div>
</div>
</div>
@@ -345,17 +359,15 @@ const messages = defineMessages({
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.duplicateInstance) }}
</h2>
<ButtonStyled>
<button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
class="w-max !shadow-none"
@click="duplicateInstance"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button>
</ButtonStyled>
<Button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
class="w-max"
@click="duplicateInstance"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</Button>
<p class="m-0">
{{ formatMessage(messages.duplicateInstanceDescription) }}
</p>
@@ -381,11 +393,9 @@ const messages = defineMessages({
class="w-full max-w-[300px]"
@submit="() => addCategory"
/>
<ButtonStyled>
<button class="w-fit !shadow-none" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button>
</ButtonStyled>
<Button class="w-fit" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</Button>
</div>
</div>
<p class="m-0">
@@ -414,22 +424,22 @@ const messages = defineMessages({
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<ButtonStyled color="red">
<button
aria-labelledby="delete-instance-label"
:disabled="removing"
class="w-fit !shadow-none"
@click="deleteConfirmModal.show()"
>
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</button>
</ButtonStyled>
<Button
type="colored"
color="red"
aria-labelledby="delete-instance-label"
:disabled="removing"
class="w-fit"
@click="deleteConfirmModal.show()"
>
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</Button>
<p class="m-0">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
@@ -9,7 +9,7 @@ import {
XCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Button,
Checkbox,
defineMessages,
injectNotificationManager,
@@ -224,45 +224,60 @@ const messages = defineMessages({
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<ButtonStyled
<Button
type="quiet"
:color="
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
: undefined
"
color-fill="text"
:disabled="!overrideJavaInstall || testingJava"
:style="{
'--legacy-button-color':
(!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard') &&
(!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard') !== 'standard'
? `var(--color-${
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
})`
: undefined,
}"
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<button
:disabled="!overrideJavaInstall || testingJava"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
</button>
</ButtonStyled>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
</Button>
</div>
<div v-if="overrideJavaInstall" class="flex gap-2">
<ButtonStyled>
<button @click="handleDetectJava">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
<Button @click="handleDetectJava">
<SearchIcon />
Detect
</Button>
<Button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</Button>
</div>
</div>
</div>
@@ -1,30 +1,192 @@
<template>
<div>
<div class="flex flex-col gap-8">
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.activeInvitesTitle) }}
</h3>
<p class="m-0 text-secondary">
{{ formatMessage(messages.activeInvitesDescription) }}
</p>
</div>
<Table :columns="inviteColumns" :data="activeInvites" row-key="id" table-min-width="36rem">
<template #empty-state>
<div class="flex h-40 items-center justify-center text-secondary">
<SpinnerIcon
v-if="activeInvitesQuery.isLoading.value"
class="animate-spin"
aria-hidden="true"
/>
<template v-else>
{{ formatMessage(messages.noActiveInvites) }}
</template>
</div>
</template>
<template #cell-id="{ row }">
<CopyCode
:text="`${config.siteUrl}/share/${encodeURIComponent(row.id)}`"
:display-text="`/${row.id}`"
/>
</template>
<template #cell-uses="{ row }">
<span class="font-medium text-primary">{{ row.uses }}</span>
<span> / {{ row.maxUses }}</span>
</template>
<template #cell-expiration="{ row }">
<span v-tooltip="formatDateTime(row.expiration)" class="whitespace-nowrap">
{{ formatRelativeTime(row.expiration) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<IconButton
v-tooltip="formatMessage(messages.revokeInvite)"
type="quiet"
:label="
formatMessage(messages.revokeInviteWithCode, {
code: row.id,
})
"
:disabled="revokeInviteMutation.isPending.value || isBusy"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="revokeInviteModal?.show(row.id)"
>
<SpinnerIcon
v-if="
revokeInviteMutation.isPending.value &&
revokeInviteMutation.variables.value?.inviteId === row.id
"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
</IconButton>
</div>
</template>
</Table>
</section>
<SharedInstanceInstallationSettingsControls
can-unpublish
:busy="isBusy"
:unpublishing="unpublishing"
:unpublish="unpublishSharedInstance"
/>
<ConfirmRevokeSharedInstanceInviteModal ref="revokeInviteModal" @revoke="revokeInvite" />
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { SpinnerIcon, XIcon } from '@modrinth/assets'
import { IconButton } from '@modrinth/ui'
import {
CopyCode,
defineMessages,
Table,
type TableColumn,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { unpublish_shared_instance } from '@/helpers/instance'
import { config } from '@/config'
import {
get_shared_instance_invites,
revoke_shared_instance_invite,
type SharedInstanceInvite,
unpublish_shared_instance,
} from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance, offline, onUnlinked } = injectInstanceSettings()
const { handleError } = injectNotificationManager()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const unpublishing = ref(false)
const isBusy = computed(
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
const revokeInviteModal = ref<InstanceType<typeof ConfirmRevokeSharedInstanceInviteModal>>()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
type InviteTableColumn = 'id' | 'uses' | 'expiration' | 'actions'
type ActiveInvitesQueryKey = readonly ['sharedInstanceInvites', string]
const activeInvitesQueryKey = computed(
() => ['sharedInstanceInvites', instance.value.id] as const satisfies ActiveInvitesQueryKey,
)
const activeInvitesQuery = useQuery({
queryKey: activeInvitesQueryKey,
queryFn: async ({ queryKey }) => {
try {
return await get_shared_instance_invites(queryKey[1])
} catch (error) {
notifySharedInstanceError(error)
throw error
}
},
enabled: () => !!instance.value.id && !offline,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnReconnect: false,
refetchOnWindowFocus: false,
})
const activeInvites = computed(() => activeInvitesQuery.data.value ?? [])
const revokeInviteMutation = useMutation({
mutationFn: ({ instanceId, inviteId }: { instanceId: string; inviteId: string }) =>
revoke_shared_instance_invite(instanceId, inviteId),
onSuccess: (_data, { instanceId, inviteId }) => {
queryClient.setQueryData<SharedInstanceInvite[]>(
['sharedInstanceInvites', instanceId],
(invites = []) => invites.filter((invite) => invite.id !== inviteId),
)
},
onError: notifySharedInstanceError,
})
const isBusy = computed(
() =>
instance.value.install_stage !== 'installed' ||
unpublishing.value ||
revokeInviteMutation.isPending.value ||
!!offline,
)
const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
{
key: 'id',
label: formatMessage(messages.inviteCodeLabel),
width: 'clamp(11rem, 34%, 19rem)',
},
{
key: 'uses',
label: formatMessage(messages.usesLabel),
width: 'clamp(7rem, 18%, 10rem)',
},
{
key: 'expiration',
label: formatMessage(messages.expiresLabel),
width: 'clamp(9rem, 28%, 14rem)',
},
{
key: 'actions',
label: formatMessage(messages.actionsLabel),
align: 'right',
width: '5.5rem',
},
])
function revokeInvite(inviteId: string) {
if (revokeInviteMutation.isPending.value) return
revokeInviteMutation.mutate({ instanceId: instance.value.id, inviteId })
}
async function unpublishSharedInstance() {
unpublishing.value = true
@@ -34,9 +196,48 @@ async function unpublishSharedInstance() {
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
notifySharedInstanceError(error)
} finally {
unpublishing.value = false
}
}
const messages = defineMessages({
activeInvitesTitle: {
id: 'instance.settings.sharing.active-invites.title',
defaultMessage: 'Active invites',
},
activeInvitesDescription: {
id: 'instance.settings.sharing.active-invites.description',
defaultMessage: 'Anyone with one of these invite links can join while it remains active.',
},
inviteCodeLabel: {
id: 'instance.settings.sharing.active-invites.code',
defaultMessage: 'Invite link',
},
usesLabel: {
id: 'instance.settings.sharing.active-invites.uses',
defaultMessage: 'Uses',
},
expiresLabel: {
id: 'instance.settings.sharing.active-invites.expires',
defaultMessage: 'Expires',
},
actionsLabel: {
id: 'instance.settings.sharing.active-invites.actions',
defaultMessage: 'Actions',
},
noActiveInvites: {
id: 'instance.settings.sharing.active-invites.empty',
defaultMessage: 'There are no active invites.',
},
revokeInvite: {
id: 'instance.settings.sharing.active-invites.revoke',
defaultMessage: 'Revoke invite',
},
revokeInviteWithCode: {
id: 'instance.settings.sharing.active-invites.revoke-with-code',
defaultMessage: 'Revoke invite {code}',
},
})
</script>
@@ -7,7 +7,7 @@ import {
MessagesSquareIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Admonition, ButtonStyled, Collapsible, NewModal } from '@modrinth/ui'
import { Admonition, Button, ButtonLink, Collapsible, IconButton, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
@@ -137,16 +137,18 @@ async function copyToClipboard(text: string) {
<!-- Action buttons -->
<div class="flex items-center gap-2">
<ButtonStyled>
<a href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()">
<MessagesSquareIcon /> Contact support
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loadingSignIn" class="!w-full" @click="signInAgain">
<LogInIcon /> Sign in again
</button>
</ButtonStyled>
<ButtonLink href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()">
<MessagesSquareIcon /> Contact support
</ButtonLink>
<Button
type="colored"
color="brand"
:disabled="loadingSignIn"
class="!w-full"
@click="signInAgain"
>
<LogInIcon /> Sign in again
</Button>
</div>
<div class="flex flex-col gap-2">
@@ -176,16 +178,15 @@ async function copyToClipboard(text: string) {
>
{{ debugInfo }}
</div>
<ButtonStyled circular>
<button
v-tooltip="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</button>
</ButtonStyled>
<IconButton
v-tooltip="'Copy debug info'"
:label="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</IconButton>
</div>
</Collapsible>
</div>
@@ -21,38 +21,33 @@
<div class="flex flex-col gap-6 px-6 pb-6">
<div class="flex justify-end gap-2">
<ButtonStyled>
<a class="w-full !shadow-none" href="https://support.modrinth.com" @click="modal?.hide()">
<MessagesSquareIcon />
{{ formatMessage(messages.getSupport) }}
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="w-full !shadow-none" :disabled="loadingSignIn" @click="signIn">
<SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
<svg
v-else
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect
x="10.75"
y="10.75"
width="9.25"
height="9.25"
fill="black"
fill-opacity="0.9"
/>
</svg>
{{ formatMessage(messages.signIn) }}
</button>
</ButtonStyled>
<ButtonLink class="w-full" href="https://support.modrinth.com" @click="modal?.hide()">
<MessagesSquareIcon />
{{ formatMessage(messages.getSupport) }}
</ButtonLink>
<Button
type="colored"
color="brand"
class="w-full"
:disabled="loadingSignIn"
@click="signIn"
>
<SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
<svg
v-else
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect x="10.75" y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
</svg>
{{ formatMessage(messages.signIn) }}
</Button>
</div>
<p class="m-0 text-center text-sm text-secondary">
{{ formatMessage(messages.dontHaveAccount) }}
@@ -69,7 +64,7 @@
<script setup lang="ts">
import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { inject, type Ref, ref } from 'vue'
import steveImage from '@/assets/steve-look-up-left.webp'
@@ -3,12 +3,14 @@ import {
CoffeeIcon,
GameIcon,
GaugeIcon,
HeartHandshakeIcon,
LanguagesIcon,
ModrinthIcon,
PaintbrushIcon,
SettingsIcon,
Settings2Icon,
ShieldIcon,
ToggleRightIcon,
UserIcon,
} from '@modrinth/assets'
import {
commonMessages,
@@ -17,23 +19,32 @@ import {
defineMessages,
ProgressBar,
TabbedModal,
UnsavedChangesPopup,
useVIntl,
} from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { ref, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import LanguageSettings from '@/components/ui/settings/LanguageSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import PrivacySettings from '@/components/ui/settings/account/PrivacySettings.vue'
import ProfileSettings from '@/components/ui/settings/account/ProfileSettings.vue'
import SocialSettings from '@/components/ui/settings/account/SocialSettings.vue'
import AppearanceSettings from '@/components/ui/settings/display/AppearanceSettings.vue'
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.vue'
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 { 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 { formatMessage } = useVIntl()
@@ -45,71 +56,162 @@ const developerModeEnabled = defineMessage({
defaultMessage: 'Developer mode enabled.',
})
const tabCategories = defineMessages({
display: {
id: 'settings.sidebar.label.display',
defaultMessage: 'Display',
},
account: {
id: 'settings.sidebar.label.account',
defaultMessage: 'Account',
},
instances: {
id: 'app.settings.sidebar.label.instances',
defaultMessage: 'Instances',
},
})
const tabs = [
{
name: defineMessage({
id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance',
}),
category: tabCategories.display,
icon: PaintbrushIcon,
content: AppearanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.behavior',
defaultMessage: 'Behavior',
}),
category: tabCategories.display,
icon: Settings2Icon,
content: BehaviorSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.language',
defaultMessage: 'Language',
}),
category: tabCategories.display,
icon: LanguagesIcon,
content: LanguageSettings,
badge: commonMessages.beta,
},
{
name: commonSettingsMessages.featureFlags,
category: tabCategories.display,
icon: ToggleRightIcon,
content: FeatureFlagSettings,
developerOnly: true,
},
{
name: commonSettingsMessages.profile,
category: tabCategories.account,
icon: UserIcon,
content: ProfileSettings,
},
{
name: commonSettingsMessages.social,
category: tabCategories.account,
icon: HeartHandshakeIcon,
content: SocialSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.privacy',
defaultMessage: 'Privacy',
}),
category: tabCategories.account,
icon: ShieldIcon,
content: PrivacySettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default game options',
}),
category: tabCategories.instances,
icon: GameIcon,
content: DefaultInstanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.java-installations',
defaultMessage: 'Java installations',
}),
category: tabCategories.instances,
icon: CoffeeIcon,
content: JavaSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.default-instance-options',
defaultMessage: 'Default instance options',
}),
icon: GameIcon,
content: DefaultInstanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.resource-management',
defaultMessage: 'Resource management',
}),
category: tabCategories.instances,
icon: GaugeIcon,
content: ResourceManagementSettings,
},
{
name: commonSettingsMessages.featureFlags,
icon: ToggleRightIcon,
content: FeatureFlagSettings,
developerOnly: true,
},
]
const availableTabs = computed(() => tabs.filter((tab) => !tab.developerOnly || themeStore.devMode))
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const unsavedChangesPopup = ref<{ nudge: () => void } | null>(null)
const unsavedChangesController = ref<UnsavedChangesController | null>(null)
const emptyUnsavedChangesState: Record<string, unknown> = {}
const originalUnsavedChangesState = computed(
() => unsavedChangesController.value?.getOriginal() ?? emptyUnsavedChangesState,
)
const modifiedUnsavedChangesState = computed(
() => unsavedChangesController.value?.getModified() ?? emptyUnsavedChangesState,
)
const savingUnsavedChanges = computed(() => unsavedChangesController.value?.isSaving() ?? false)
const hasUnsavedChanges = computed(() => unsavedChangesController.value?.hasChanges() ?? false)
function canLeaveCurrentTab(): boolean {
if (!unsavedChangesController.value?.hasChanges()) return true
unsavedChangesPopup.value?.nudge()
return false
}
function close(): boolean {
return modal.value?.hide() ?? false
}
function registerUnsavedChangesController(controller: UnsavedChangesController | null): void {
unsavedChangesController.value = controller
}
provide(appSettingsModalContextKey, {
close,
registerUnsavedChangesController,
})
function resetUnsavedChanges(): void {
unsavedChangesController.value?.reset()
}
function saveUnsavedChanges(): void {
void unsavedChangesController.value?.save()
}
function show() {
modal.value?.show()
}
defineExpose({ show })
function showProfile(): void {
const profileTabIndex = availableTabs.value.findIndex((tab) => tab.content === ProfileSettings)
if (profileTabIndex >= 0) {
modal.value?.setTab(profileTabIndex)
}
modal.value?.show()
}
defineExpose({ show, showProfile })
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
@@ -129,12 +231,15 @@ watch(
function devModeCount() {
devModeCounter.value++
if (devModeCounter.value > 5) {
const selectedTab = modal.value ? availableTabs.value[modal.value.selectedTab] : undefined
themeStore.devMode = !themeStore.devMode
settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value!.selectedTab].developerOnly) {
modal.value!.setTab(0)
if (modal.value) {
const selectedTabIndex = selectedTab ? availableTabs.value.indexOf(selectedTab) : -1
modal.value.setTab(selectedTabIndex >= 0 ? selectedTabIndex : 0)
}
}
}
@@ -144,15 +249,45 @@ const messages = defineMessages({
id: 'app.settings.downloading',
defaultMessage: 'Downloading v{version}',
},
appVersion: {
id: 'app.settings.app-version',
defaultMessage: 'Modrinth App {version}',
},
macos: {
id: 'app.settings.operating-system.macos',
defaultMessage: 'macOS',
},
developerModeButtonLabel: {
id: 'app.settings.developer-mode-button.label',
defaultMessage: 'Toggle developer mode',
},
})
</script>
<template>
<TabbedModal ref="modal" :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<TabbedModal
ref="modal"
:tabs="availableTabs"
:width="'min(928px, calc(95vw - 10rem))'"
:before-hide="canLeaveCurrentTab"
:before-tab-change="canLeaveCurrentTab"
:floating-action-bar-shown="hasUnsavedChanges"
>
<template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Settings
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(commonMessages.settingsLabel) }}
</span>
</template>
<template #floating-action-bar>
<UnsavedChangesPopup
ref="unsavedChangesPopup"
:original="originalUnsavedChangesState"
:modified="modifiedUnsavedChangesState"
:saving="savingUnsavedChanges"
inline
@reset="resetUnsavedChanges"
@save="saveUnsavedChanges"
/>
</template>
<template #footer>
<div class="mt-auto text-secondary text-sm">
<div class="mb-3">
@@ -168,6 +303,7 @@ const messages = defineMessages({
</p>
<div class="flex items-center gap-3">
<button
:aria-label="formatMessage(messages.developerModeButtonLabel)"
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{
'text-brand': themeStore.devMode,
@@ -175,12 +311,14 @@ const messages = defineMessages({
}"
@click="devModeCount"
>
<ModrinthIcon class="w-6 h-6" />
<ModrinthIcon aria-hidden="true" class="w-6 h-6" />
</button>
<div class="max-w-[200px]">
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">macOS</span>
{{ formatMessage(messages.appVersion, { version }) }}
</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">{{ formatMessage(messages.macos) }}</span>
<span v-else class="capitalize">{{ osPlatform }}</span>
{{ osVersion }}
</p>
@@ -6,18 +6,14 @@
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<TrashIcon />
{{ formatMessage(messages.deleteButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="red" @click="confirm">
<TrashIcon />
{{ formatMessage(messages.deleteButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -27,7 +23,7 @@
import { TrashIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Button,
commonMessages,
defineMessages,
NewModal,
@@ -18,12 +18,10 @@
{{ formatMessage(messages.sharedInstance) }}
</span>
<ButtonStyled type="transparent">
<button @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
<Button type="quiet" @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</Button>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
@@ -113,40 +111,30 @@
</p>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled type="transparent" color="red">
<button @click="handleReport">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<Button type="quiet" color="red" @click="handleReport">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</Button>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange">
<button @click="handleAccept">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleDecline">
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
<Button type="quiet" color="orange" @click="handleAccept">
{{ formatMessage(messages.installAnyway) }}
</Button>
<Button type="colored" color="brand" @click="handleDecline">
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</Button>
</template>
<template v-else>
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
<Button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</Button>
</template>
</div>
</div>
@@ -163,10 +151,10 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, EyeIcon, ReportIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
type ContentItem,
defineMessages,
@@ -10,24 +10,18 @@
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleCreateAnyway">
<PlusIcon />
{{ formatMessage(messages.create) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</Button>
<Button type="colored" color="orange" @click="handleCreateAnyway">
<PlusIcon />
{{ formatMessage(messages.create) }}
</Button>
</div>
</template>
</NewModal>
@@ -36,7 +30,7 @@
<script setup lang="ts">
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessages,
IntlFormatted,
@@ -19,18 +19,20 @@
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button class="w-full !shadow-none" type="button" @click="authenticate('sign-up')">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.createAccountButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="w-full" type="button" @click="authenticate('sign-in')">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
<Button class="w-full" native-type="button" @click="authenticate('sign-up')">
<UserPlusIcon aria-hidden="true" />
{{ formatMessage(messages.createAccountButton) }}
</Button>
<Button
type="colored"
color="brand"
class="w-full"
native-type="button"
@click="authenticate('sign-in')"
>
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</Button>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
@@ -69,23 +71,19 @@
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled type="outlined">
<button class="w-full" type="button" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button
class="w-full !shadow-none"
type="button"
:disabled="reopeningBrowser"
@click="reopenBrowser"
>
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.openBrowserAgainButton) }}
</button>
</ButtonStyled>
<Button type="outlined" class="w-full" native-type="button" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</Button>
<Button
class="w-full"
native-type="button"
:disabled="reopeningBrowser"
@click="reopenBrowser"
>
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.openBrowserAgainButton) }}
</Button>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
@@ -108,7 +106,7 @@
<script setup lang="ts">
import { LogInIcon, RefreshCwIcon, SpinnerIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
import { Button, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
@@ -1,335 +0,0 @@
<script setup lang="ts">
import { Combobox, defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
const messages = defineMessages({
colorThemeTitle: {
id: 'app.appearance-settings.color-theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'app.appearance-settings.color-theme.description',
defaultMessage: 'Select your preferred color theme for Modrinth App.',
},
advancedRenderingTitle: {
id: 'app.appearance-settings.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'app.appearance-settings.advanced-rendering.description',
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
hideNametagTitle: {
id: 'app.appearance-settings.hide-nametag.title',
defaultMessage: 'Hide nametag',
},
hideNametagDescription: {
id: 'app.appearance-settings.hide-nametag.description',
defaultMessage: 'Disables the nametag above your player on the skins page.',
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'Native decorations',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage: 'Use system window frame (app restart required).',
},
minimizeLauncherTitle: {
id: 'app.appearance-settings.minimize-launcher.title',
defaultMessage: 'Minimize launcher',
},
minimizeLauncherDescription: {
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize the launcher when a Minecraft process starts.',
},
defaultLandingPageTitle: {
id: 'app.appearance-settings.default-landing-page.title',
defaultMessage: 'Default landing page',
},
defaultLandingPageDescription: {
id: 'app.appearance-settings.default-landing-page.description',
defaultMessage: 'Change the page to which the launcher opens on.',
},
defaultLandingPageHome: {
id: 'app.appearance-settings.default-landing-page.home',
defaultMessage: 'Home',
},
defaultLandingPageLibrary: {
id: 'app.appearance-settings.default-landing-page.library',
defaultMessage: 'Library',
},
selectOption: {
id: 'app.appearance-settings.select-option',
defaultMessage: 'Select an option',
},
jumpBackIntoWorldsTitle: {
id: 'app.appearance-settings.jump-back-into-worlds.title',
defaultMessage: 'Jump back into worlds',
},
jumpBackIntoWorldsDescription: {
id: 'app.appearance-settings.jump-back-into-worlds.description',
defaultMessage: 'Includes recent worlds in the "Jump back in" section on the Home page.',
},
toggleSidebarTitle: {
id: 'app.appearance-settings.toggle-sidebar.title',
defaultMessage: 'Toggle sidebar',
},
toggleSidebarDescription: {
id: 'app.appearance-settings.toggle-sidebar.description',
defaultMessage: 'Enables the ability to toggle the sidebar.',
},
unknownPackWarningTitle: {
id: 'app.appearance-settings.unknown-pack-warning.title',
defaultMessage: 'Warn me before installing unknown modpacks',
},
unknownPackWarningDescription: {
id: 'app.appearance-settings.unknown-pack-warning.description',
defaultMessage:
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
},
skipNonEssentialWarningsTitle: {
id: 'app.appearance-settings.skip-non-essential-warnings.title',
defaultMessage: 'Skip non-essential warnings',
},
skipNonEssentialWarningsDescription: {
id: 'app.appearance-settings.skip-non-essential-warnings.description',
defaultMessage:
'Automatically skips low-risk confirmations like duplicate modpack installs, normal content deletion, bulk updates, unlinking modpacks, and repair prompts. Dangerous warnings will still be shown.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
},
showPlayTimeDescription: {
id: 'app.appearance-settings.show-play-time.description',
defaultMessage: `Displays how much time you've spent playing an instance.`,
},
})
const os = ref(await getOS())
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: 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>
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()"
system-theme-color="system"
/>
<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>
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
}
"
/>
</div>
<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>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.minimizeLauncherTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.minimizeLauncherDescription) }}</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showPlayTimeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hideNametagTitle) }}
</h2>
<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
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.defaultLandingPageTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.defaultLandingPageDescription) }}</p>
</div>
<Combobox
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="max-w-40"
:options="[
{
value: 'Home',
label: formatMessage(messages.defaultLandingPageHome),
},
{
value: 'Library',
label: formatMessage(messages.defaultLandingPageLibrary),
},
]"
:display-value="settings.default_page ?? 'Select an option'"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.jumpBackIntoWorldsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.unknownPackWarningTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.unknownPackWarningDescription) }}</p>
</div>
<Toggle
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.skipNonEssentialWarningsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.toggleSidebarTitle) }}
</h2>
<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
}
"
/>
</div>
</template>
@@ -1,186 +0,0 @@
<script setup lang="ts">
import { injectNotificationManager, Slider, StyledInput, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { get, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = setSettings.envVars
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null
}
if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null
}
if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Fullscreen</h3>
<p class="m-0 leading-tight">
Overwrites the options.txt file to start in full screen when launched.
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Width</h3>
<p class="m-0 leading-tight">The width of the game window when launched.</p>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Height</h3>
<p class="m-0 leading-tight">The height of the game window when launched.</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter height..."
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Memory allocated</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Java arguments</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Environmental variables</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Pre launch hook</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran before the instance is launched.</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Wrapper hook</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Wrapper command for launching Minecraft.</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Post exit hook</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran after the game closes.</p>
</div>
</div>
</div>
</template>
@@ -1,168 +0,0 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Slider, StyledInput } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
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 themeStore = useTheming()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'version',
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
purgeCacheConfirmModal.value?.show()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: 'Select a new app directory',
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">App directory</h2>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<ButtonStyled circular>
<button class="ml-1.5" @click="findLauncherDir">
<FolderSearchIcon />
</button>
</ButtonStyled>
</template>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
</div>
<div class="flex flex-col gap-2.5">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">App cache</h2>
<button id="purge-cache" class="btn min-w-max" @click="handlePurgeCacheClick">
<TrashIcon />
Purge cache
</button>
<p class="m-0 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast mt-4">Maximum concurrent downloads</h2>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">Maximum concurrent writes</h2>
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">App database backups</h2>
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder">
<FolderOpenIcon />
Open backups folder
</button>
<p class="m-0 leading-tight text-secondary">
Backups of important app data are stored here in case you need to recover them later.
</p>
</div>
</div>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Settings2Icon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
defineMessages,
injectNotificationManager,
injectPageContext,
@@ -33,6 +33,24 @@ const messages = defineMessages({
id: 'app.ads-consent.manage',
defaultMessage: 'Manage preferences',
},
telemetryTitle: {
id: 'app.settings.privacy.telemetry.title',
defaultMessage: 'Telemetry',
},
telemetryDescription: {
id: 'app.settings.privacy.telemetry.description',
defaultMessage:
'Modrinth collects anonymized analytics and usage data to improve our user experience and customize your experience. By disabling this option, you opt out and your data will no longer be collected.',
},
discordRichPresenceTitle: {
id: 'app.settings.privacy.discord-rich-presence.title',
defaultMessage: 'Discord Rich Presence',
},
discordRichPresenceDescription: {
id: 'app.settings.privacy.discord-rich-presence.description',
defaultMessage:
'Show Modrinth App as your current activity on Discord. This does not affect Rich Presence added to instances by mods. Requires an app restart.',
},
})
async function manageAdsPreferences() {
@@ -59,28 +77,24 @@ watch(
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.adsConsentTitle) }}
</h2>
<div class="mt-1 flex flex-col gap-2.5 items-start">
<div class="flex flex-col gap-1 items-start">
<div class="text-sm">
{{ formatMessage(messages.adsConsentIntro) }}
</div>
<div class="mt-2 flex flex-col gap-2.5 items-start">
<Button @click="manageAdsPreferences">
<Settings2Icon aria-hidden="true" />
{{ formatMessage(messages.adsConsentManage) }}
</Button>
<div>
{{ formatMessage(messages.adsConsentIntro) }}
</div>
<ButtonStyled>
<button class="!shadow-none" @click="manageAdsPreferences">
<Settings2Icon aria-hidden="true" />
{{ formatMessage(messages.adsConsentManage) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="mt-8 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
<p class="m-0 mt-1 text-sm">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no
longer be collected.
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.telemetryTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.telemetryDescription) }}
</p>
</div>
<Toggle id="opt-out-analytics" v-model="settings.telemetry" />
@@ -88,14 +102,11 @@ watch(
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Discord RPC</h2>
<p class="m-0 mt-1 text-sm">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
longer show up as a game or app you are using on your Discord profile.
</p>
<p class="m-0 mt-2 text-sm">
Note: This will not prevent any instance-specific Discord Rich Presence integrations, such
as those added by mods. (app restart required to take effect)
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.discordRichPresenceTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.discordRichPresenceDescription) }}
</p>
</div>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
@@ -0,0 +1,70 @@
<template>
<AccountProfileSettings
ref="profileSettings"
:patch-user="patchUser"
:change-avatar="changeAvatar"
:delete-avatar="deleteAvatar"
:get-authenticated-user="getAuthenticatedUser"
@profile-link-click="handleProfileLinkClick"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountProfileSettings, injectAuth } from '@modrinth/ui'
import { inject, onBeforeUnmount, onMounted, ref } from 'vue'
import {
change_user_avatar,
delete_user_avatar,
get_user_profile,
patch_user,
} from '@/helpers/users'
import { appSettingsModalContextKey } from '@/providers/app-settings-modal'
const settingsModal = inject(appSettingsModalContextKey, null)
const auth = injectAuth()
const profileSettings = ref<InstanceType<typeof AccountProfileSettings> | null>(null)
onMounted(() => {
settingsModal?.registerUnsavedChangesController({
hasChanges: () => profileSettings.value?.hasChanges ?? false,
getOriginal: () => profileSettings.value?.originalState ?? {},
getModified: () => profileSettings.value?.modifiedState ?? {},
isSaving: () => profileSettings.value?.saving ?? false,
reset: () => profileSettings.value?.reset(),
save: () => profileSettings.value?.save(),
})
})
onBeforeUnmount(() => {
settingsModal?.registerUnsavedChangesController(null)
})
function handleProfileLinkClick(event: MouseEvent): void {
if (settingsModal && !settingsModal.close()) {
event.preventDefault()
}
}
function patchUser(
userId: string,
patch: Partial<Pick<Labrinth.Users.v2.User, 'bio' | 'username'>>,
): Promise<void> {
return patch_user(userId, patch)
}
async function changeAvatar(userId: string, file: Blob, extension: string): Promise<void> {
await change_user_avatar(userId, new Uint8Array(await file.arrayBuffer()), extension)
}
function deleteAvatar(userId: string): Promise<void> {
return delete_user_avatar(userId)
}
function getAuthenticatedUser(): Promise<Labrinth.Users.v3.User> {
const userId = auth.user.value?.id
if (!userId) throw new Error('Cannot refresh a signed-out user.')
return get_user_profile(userId)
}
</script>
@@ -0,0 +1,20 @@
<template>
<AccountSocialSettings
:get-blocked-users="get_blocked_users"
:get-users="getUsers"
:unblock-user="unblock_user"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { AccountSocialSettings, injectModrinthClient } from '@modrinth/ui'
import { get_blocked_users, unblock_user } from '@/helpers/users'
const client = injectModrinthClient()
function getUsers(userIds: string[]): Promise<Labrinth.Users.v2.User[]> {
return client.labrinth.users_v2.getMultiple(userIds)
}
</script>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme } from '@/store/theme.ts'
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 settings = ref(await get())
const themeOptions = computed(() =>
themeStore
.getThemeOptions()
.filter((theme) => theme !== 'retro' || themeStore.devMode || settings.value.theme === 'retro'),
)
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: 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>
<ThemeSelector
:update-color-theme="
(theme: ColorTheme) => {
themeStore.setThemeState(theme)
settings.theme = theme
}
"
:current-theme="settings.theme"
:theme-options="themeOptions"
system-theme-color="system"
/>
<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>
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
settings.advanced_rendering = themeStore.advancedRendering
}
"
/>
</div>
<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>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { Chips, defineMessages, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
type LandingPage = 'Home' | 'Library'
const landingPageOptions: LandingPage[] = ['Home', 'Library']
const messages = defineMessages({
startupAndNavigationTitle: {
id: 'app.behavior-settings.startup-and-navigation.title',
defaultMessage: 'Startup and navigation',
},
contentTitle: {
id: 'app.behavior-settings.content.title',
defaultMessage: 'Home and content',
},
confirmationsTitle: {
id: 'app.behavior-settings.confirmations.title',
defaultMessage: 'Confirmations',
},
minimizeLauncherTitle: {
id: 'app.appearance-settings.minimize-launcher.title',
defaultMessage: 'Minimize app',
},
minimizeLauncherDescription: {
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize Modrinth App when Minecraft starts.',
},
defaultLandingPageTitle: {
id: 'app.appearance-settings.default-landing-page.title',
defaultMessage: 'Default landing page',
},
defaultLandingPageDescription: {
id: 'app.appearance-settings.default-landing-page.description',
defaultMessage: 'Choose the page shown when Modrinth App opens.',
},
defaultLandingPageHome: {
id: 'app.appearance-settings.default-landing-page.home',
defaultMessage: 'Home',
},
defaultLandingPageLibrary: {
id: 'app.appearance-settings.default-landing-page.library',
defaultMessage: 'Library',
},
toggleSidebarTitle: {
id: 'app.appearance-settings.toggle-sidebar.title',
defaultMessage: 'Hide right sidebar',
},
toggleSidebarDescription: {
id: 'app.appearance-settings.toggle-sidebar.description',
defaultMessage: 'Hide the right sidebar by default and add a button to show or hide it.',
},
jumpBackIntoWorldsTitle: {
id: 'app.appearance-settings.jump-back-into-worlds.title',
defaultMessage: 'Jump back into worlds',
},
jumpBackIntoWorldsDescription: {
id: 'app.appearance-settings.jump-back-into-worlds.description',
defaultMessage: 'Show recently played worlds in the "Jump back in" section on the Home page.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
},
showPlayTimeDescription: {
id: 'app.appearance-settings.show-play-time.description',
defaultMessage: `Show how long you've played each instance.`,
},
hideNametagTitle: {
id: 'app.appearance-settings.hide-nametag.title',
defaultMessage: 'Hide nametag',
},
hideNametagDescription: {
id: 'app.appearance-settings.hide-nametag.description',
defaultMessage: 'Hide your username above the player preview on the Skin selector page.',
},
unknownPackWarningTitle: {
id: 'app.appearance-settings.unknown-pack-warning.title',
defaultMessage: 'Warn me before installing unknown modpacks',
},
unknownPackWarningDescription: {
id: 'app.appearance-settings.unknown-pack-warning.description',
defaultMessage:
"Show a safety warning before installing a Modrinth Pack (.mrpack) that isn't hosted on Modrinth.",
},
skipNonEssentialWarningsTitle: {
id: 'app.appearance-settings.skip-non-essential-warnings.title',
defaultMessage: 'Skip non-essential warnings',
},
skipNonEssentialWarningsDescription: {
id: 'app.appearance-settings.skip-non-essential-warnings.description',
defaultMessage:
'Skip confirmations for low-risk actions such as duplicate installs, normal content deletion, bulk updates, unlinking, and repairs. Warnings for dangerous actions are always shown.',
},
})
function formatLandingPageLabel(page: LandingPage) {
switch (page) {
case 'Home':
return formatMessage(messages.defaultLandingPageHome)
case 'Library':
return formatMessage(messages.defaultLandingPageLibrary)
}
}
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
</script>
<template>
<section>
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.startupAndNavigationTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.defaultLandingPageTitle) }}
</h3>
<Chips
v-model="settings.default_page"
:items="landingPageOptions"
:format-label="formatLandingPageLabel"
:capitalize="false"
:aria-label="formatMessage(messages.defaultLandingPageTitle)"
/>
<p class="m-0">
{{ formatMessage(messages.defaultLandingPageDescription) }}
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.minimizeLauncherTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.minimizeLauncherDescription) }}
</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.toggleSidebarTitle) }}
</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
}
"
/>
</div>
</div>
</section>
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.contentTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.jumpBackIntoWorldsTitle) }}
</h3>
<p class="m-0 mt-1">
{{ 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
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showPlayTimeTitle) }}
</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
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hideNametagTitle) }}
</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
}
"
/>
</div>
</div>
</section>
<section class="mt-8 border-0 border-t border-solid border-divider pt-6">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.confirmationsTitle) }}
</h2>
<div class="mt-4 flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.unknownPackWarningTitle) }}
</h3>
<p class="m-0 mt-1">
{{ formatMessage(messages.unknownPackWarningDescription) }}
</p>
</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
}
"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.skipNonEssentialWarningsTitle) }}
</h3>
<p class="m-0 mt-1">
{{ 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
}
"
/>
</div>
</div>
</section>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ButtonStyled, Toggle } from '@modrinth/ui'
import { Button, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -25,7 +25,7 @@ watch(
)
</script>
<template>
<div class="flex flex-col gap-2.5 min-w-[600px]">
<div class="flex flex-col gap-2.5">
<div v-for="option in options" :key="option" class="flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
@@ -33,14 +33,13 @@ watch(
</h2>
</div>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent">
<button
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</button>
</ButtonStyled>
<Button
type="quiet"
:disabled="themeStore.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)"
@@ -2,6 +2,7 @@
import {
Admonition,
AutoLink,
commonSettingsMessages,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
@@ -43,7 +44,9 @@ async function onLocaleChange(newLocale: string) {
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">Language</h2>
<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 }) }}
@@ -0,0 +1,333 @@
<script setup lang="ts">
import {
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { get, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
fullscreenTitle: {
id: 'app.settings.default-instance-options.fullscreen.title',
defaultMessage: 'Fullscreen',
},
fullscreenDescription: {
id: 'app.settings.default-instance-options.fullscreen.description',
defaultMessage: 'Start instances in fullscreen by updating their options.txt file.',
},
widthTitle: {
id: 'app.settings.default-instance-options.width.title',
defaultMessage: 'Width',
},
widthDescription: {
id: 'app.settings.default-instance-options.width.description',
defaultMessage: 'The width of the game window when launched.',
},
widthPlaceholder: {
id: 'app.settings.default-instance-options.width.placeholder',
defaultMessage: 'Enter width...',
},
heightTitle: {
id: 'app.settings.default-instance-options.height.title',
defaultMessage: 'Height',
},
heightDescription: {
id: 'app.settings.default-instance-options.height.description',
defaultMessage: 'The height of the game window when launched.',
},
heightPlaceholder: {
id: 'app.settings.default-instance-options.height.placeholder',
defaultMessage: 'Enter height...',
},
memoryAllocationTitle: {
id: 'app.settings.default-instance-options.memory-allocation.title',
defaultMessage: 'Memory allocation',
},
memoryAllocationDescription: {
id: 'app.settings.default-instance-options.memory-allocation.description',
defaultMessage: 'Maximum memory available to each instance.',
},
javaArgumentsTitle: {
id: 'app.settings.default-instance-options.java-arguments.title',
defaultMessage: 'Java arguments',
},
javaArgumentsPlaceholder: {
id: 'app.settings.default-instance-options.java-arguments.placeholder',
defaultMessage: 'Enter Java arguments...',
},
javaArgumentsDescription: {
id: 'app.settings.default-instance-options.java-arguments.description',
defaultMessage: 'Arguments passed to Java when launching an instance.',
},
environmentVariablesTitle: {
id: 'app.settings.default-instance-options.environment-variables.title',
defaultMessage: 'Environment variables',
},
environmentVariablesPlaceholder: {
id: 'app.settings.default-instance-options.environment-variables.placeholder',
defaultMessage: 'Enter environment variables...',
},
environmentVariablesDescription: {
id: 'app.settings.default-instance-options.environment-variables.description',
defaultMessage: 'Environment variables set when launching an instance.',
},
preLaunchHookTitle: {
id: 'app.settings.default-instance-options.pre-launch-hook.title',
defaultMessage: 'Pre-launch hook',
},
preLaunchHookPlaceholder: {
id: 'app.settings.default-instance-options.pre-launch-hook.placeholder',
defaultMessage: 'Enter pre-launch command...',
},
preLaunchHookDescription: {
id: 'app.settings.default-instance-options.pre-launch-hook.description',
defaultMessage: 'Runs before the instance starts.',
},
wrapperHookTitle: {
id: 'app.settings.default-instance-options.wrapper-hook.title',
defaultMessage: 'Wrapper hook',
},
wrapperHookPlaceholder: {
id: 'app.settings.default-instance-options.wrapper-hook.placeholder',
defaultMessage: 'Enter wrapper command...',
},
wrapperHookDescription: {
id: 'app.settings.default-instance-options.wrapper-hook.description',
defaultMessage: 'Command used to wrap the Minecraft launch process.',
},
postExitHookTitle: {
id: 'app.settings.default-instance-options.post-exit-hook.title',
defaultMessage: 'Post-exit hook',
},
postExitHookPlaceholder: {
id: 'app.settings.default-instance-options.post-exit-hook.placeholder',
defaultMessage: 'Enter post-exit command...',
},
postExitHookDescription: {
id: 'app.settings.default-instance-options.post-exit-hook.description',
defaultMessage: 'Runs after the game closes.',
},
})
const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
setSettings.custom_env_vars = setSettings.envVars
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null
}
if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null
}
if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreenTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.widthTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.widthPlaceholder)"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.heightTitle) }}
</h3>
<p class="m-0 leading-tight">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.heightPlaceholder)"
/>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.memoryAllocationTitle) }}
</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">
{{ formatMessage(messages.memoryAllocationDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.javaArgumentsTitle) }}
</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.javaArgumentsPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.javaArgumentsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.environmentVariablesTitle) }}
</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.environmentVariablesPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.environmentVariablesDescription) }}
</p>
</div>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunchHookTitle) }}
</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.preLaunchHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.preLaunchHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapperHookTitle) }}
</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.wrapperHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.wrapperHookDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExitHookTitle) }}
</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.postExitHookPlaceholder)"
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">
{{ formatMessage(messages.postExitHookDescription) }}
</p>
</div>
</div>
</div>
</template>
@@ -1,11 +1,19 @@
<script setup>
import { injectNotificationManager } from '@modrinth/ui'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
javaLocation: {
id: 'app.settings.java-installations.location.title',
defaultMessage: 'Java {version, number} location',
},
})
const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) {
@@ -28,7 +36,7 @@ async function updateJavaVersion(version) {
class="flex flex-col gap-2.5"
>
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
{{ formatMessage(messages.javaLocation, { version: javaVersion }) }}
</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
@@ -0,0 +1,291 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import {
Button,
defineMessages,
IconButton,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
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 settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
const messages = defineMessages({
appDirectoryTitle: {
id: 'app.settings.resource-management.app-directory.title',
defaultMessage: 'App directory',
},
appDirectoryDescription: {
id: 'app.settings.resource-management.app-directory.description',
defaultMessage:
'Where Modrinth App stores instances and other files. Changes take effect after restarting the app.',
},
selectAppDirectory: {
id: 'app.settings.resource-management.app-directory.select',
defaultMessage: 'Select a new app directory',
},
browseAppDirectory: {
id: 'app.settings.resource-management.app-directory.browse',
defaultMessage: 'Browse for an app directory',
},
appCacheTitle: {
id: 'app.settings.resource-management.app-cache.title',
defaultMessage: 'App cache',
},
purgeCache: {
id: 'app.settings.resource-management.app-cache.purge',
defaultMessage: 'Purge cache',
},
purgeCacheConfirmTitle: {
id: 'app.settings.resource-management.app-cache.confirm.title',
defaultMessage: 'Purge the app cache?',
},
purgeCacheConfirmDescription: {
id: 'app.settings.resource-management.app-cache.confirm.description',
defaultMessage: 'The app may load more slowly until the cache is rebuilt.',
},
appCacheDescription: {
id: 'app.settings.resource-management.app-cache.description',
defaultMessage:
'Clear cached data and download it again from Modrinth. The app may load more slowly until the cache is rebuilt.',
},
maximumConcurrentDownloadsTitle: {
id: 'app.settings.resource-management.maximum-concurrent-downloads.title',
defaultMessage: 'Maximum concurrent downloads',
},
maximumConcurrentDownloadsDescription: {
id: 'app.settings.resource-management.maximum-concurrent-downloads.description',
defaultMessage:
'Number of files the app can download at once. Lower this if downloads are unreliable on your connection. Requires an app restart.',
},
maximumConcurrentWritesTitle: {
id: 'app.settings.resource-management.maximum-concurrent-writes.title',
defaultMessage: 'Maximum concurrent writes',
},
maximumConcurrentWritesDescription: {
id: 'app.settings.resource-management.maximum-concurrent-writes.description',
defaultMessage:
'Number of files the app can write to disk at once. Lower this if you frequently encounter I/O errors. Requires an app restart.',
},
alwaysShowCopyDetailsTitle: {
id: 'app.settings.resource-management.always-show-copy-details.title',
defaultMessage: 'Always show copy details',
},
alwaysShowCopyDetailsDescription: {
id: 'app.settings.resource-management.always-show-copy-details.description',
defaultMessage:
'Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs.',
},
appDatabaseBackupsTitle: {
id: 'app.settings.resource-management.app-database-backups.title',
defaultMessage: 'App database backups',
},
openBackupsFolder: {
id: 'app.settings.resource-management.app-database-backups.open-folder',
defaultMessage: 'Open backups folder',
},
appDatabaseBackupsDescription: {
id: 'app.settings.resource-management.app-database-backups.description',
defaultMessage:
'Backups of important app data are stored here in case you need to recover them later.',
},
})
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'version',
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
purgeCacheConfirmModal.value?.show()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: formatMessage(messages.selectAppDirectory),
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appDirectoryTitle) }}
</h2>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<IconButton
v-tooltip="formatMessage(messages.browseAppDirectory)"
:label="formatMessage(messages.browseAppDirectory)"
class="ml-1.5"
@click="findLauncherDir"
>
<FolderSearchIcon aria-hidden="true" />
</IconButton>
</template>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appDirectoryDescription) }}
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.alwaysShowCopyDetailsTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.alwaysShowCopyDetailsDescription) }}
</p>
</div>
<Toggle
id="always-show-copy-details"
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
/>
</div>
<div class="flex flex-col gap-2.5">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
:title="formatMessage(messages.purgeCacheConfirmTitle)"
:description="formatMessage(messages.purgeCacheConfirmDescription)"
:has-to-type="false"
:proceed-label="formatMessage(messages.purgeCache)"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appCacheTitle) }}
</h2>
<Button id="purge-cache" class="w-fit" @click="handlePurgeCacheClick">
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.purgeCache) }}
</Button>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appCacheDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast mt-4">
{{ formatMessage(messages.maximumConcurrentDownloadsTitle) }}
</h2>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.maximumConcurrentDownloadsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.maximumConcurrentWritesTitle) }}
</h2>
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.maximumConcurrentWritesDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appDatabaseBackupsTitle) }}
</h2>
<Button id="open-db-backups-folder" class="w-fit" @click="openDbBackupsFolder">
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(messages.openBackupsFolder) }}
</Button>
<p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appDatabaseBackupsDescription) }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,78 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
<IntlFormatted :message-id="messages.admonitionBody" :values="{ code: inviteCode }">
<template #monospace="{ children }">
<code class="font-mono"><component :is="() => children" /></code>
</template>
</IntlFormatted>
</Admonition>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="red" @click="confirm">
<XIcon />
{{ formatMessage(messages.revokeButton) }}
</Button>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import {
Admonition,
Button,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const inviteCode = ref('')
const emit = defineEmits<{
revoke: [inviteCode: string]
}>()
function show(code: string) {
inviteCode.value = code
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('revoke', inviteCode.value)
}
const messages = defineMessages({
header: {
id: 'instance.settings.sharing.revoke-invite.header',
defaultMessage: 'Revoke invite',
},
admonitionHeader: {
id: 'instance.settings.sharing.revoke-invite.admonition-header',
defaultMessage: 'This action cannot be undone',
},
admonitionBody: {
id: 'instance.settings.sharing.revoke-invite.admonition-body',
defaultMessage:
'The invite link <monospace>{code}</monospace> will stop working immediately. People who already joined will keep access.',
},
revokeButton: {
id: 'instance.settings.sharing.revoke-invite.confirm',
defaultMessage: 'Revoke invite',
},
})
defineExpose({ show })
</script>
@@ -10,24 +10,18 @@
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleInstallAnyway">
<DownloadIcon />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</Button>
<Button type="colored" color="orange" @click="handleInstallAnyway">
<DownloadIcon />
{{ formatMessage(messages.installAnyway) }}
</Button>
</div>
</template>
</NewModal>
@@ -36,7 +30,7 @@
<script setup lang="ts">
import { DownloadIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessages,
IntlFormatted,
@@ -2,13 +2,11 @@
<div v-if="canUnpublish" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unpublishModal?.show()">
<SpinnerIcon v-if="unpublishing" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
</button>
</ButtonStyled>
<Button type="colored" color="orange" :disabled="busy" @click="unpublishModal?.show()">
<SpinnerIcon v-if="unpublishing" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
</Button>
</div>
<span class="text-primary">{{ formatMessage(messages.unpublishDescription) }}</span>
</div>
@@ -17,13 +15,11 @@
formatMessage(messages.linkedTitle)
}}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unlinkModal?.show()">
<SpinnerIcon v-if="unlinking" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unlinking ? messages.unlinkingButton : messages.unlinkButton) }}
</button>
</ButtonStyled>
<Button type="colored" color="orange" :disabled="busy" @click="unlinkModal?.show()">
<SpinnerIcon v-if="unlinking" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unlinking ? messages.unlinkingButton : messages.unlinkButton) }}
</Button>
</div>
<span class="text-primary">{{ formatMessage(messages.unlinkDescription) }}</span>
</div>
@@ -39,15 +35,11 @@
}}</Admonition>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unpublishModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy" @click="confirmUnpublish">
<UnlinkIcon />{{ formatMessage(messages.unpublishButton) }}
</button></ButtonStyled
>
<Button type="outlined" class="!border" @click="unpublishModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }} </Button
><Button type="colored" color="orange" :disabled="busy" @click="confirmUnpublish">
<UnlinkIcon />{{ formatMessage(messages.unpublishButton) }}
</Button>
</div></template
>
</NewModal>
@@ -70,15 +62,16 @@
</div>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unlinkModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy || backupBusy" @click="confirmUnlink">
<UnlinkIcon />{{ formatMessage(messages.unlinkButton) }}
</button></ButtonStyled
<Button type="outlined" class="!border" @click="unlinkModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }} </Button
><Button
type="colored"
color="orange"
:disabled="busy || backupBusy"
@click="confirmUnlink"
>
<UnlinkIcon />{{ formatMessage(messages.unlinkButton) }}
</Button>
</div></template
>
</NewModal>
@@ -88,7 +81,7 @@
import { SpinnerIcon, UnlinkIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Button,
commonMessages,
defineMessages,
InlineBackupCreator,
@@ -26,12 +26,13 @@
</span>
</template>
<div class="flex min-w-0 flex-col gap-3 pt-4">
<div class="max-h-[292px] overflow-y-auto rounded-[20px]">
<div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect
v-model="selectedConfigPaths"
:items="configFileItems"
:show-size="false"
:show-modified="false"
@navigate="scrollConfigFileTreeToTop"
/>
</div>
</div>
@@ -75,6 +76,7 @@ const emit = defineEmits<{
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const configFileTreeContainer = ref<HTMLElement>()
const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([])
@@ -132,6 +134,12 @@ async function publishChanges() {
}
}
function scrollConfigFileTreeToTop() {
if (configFileTreeContainer.value) {
configFileTreeContainer.value.scrollTop = 0
}
}
function finishReview() {
if (state.value === 'reviewing') {
setState('idle')
@@ -43,8 +43,9 @@ import type { GameInstance } from '@/helpers/types'
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
accepted: []
cancel: []
complete: []
complete: [successful: boolean]
report: [event?: MouseEvent]
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
@@ -56,24 +57,30 @@ const { formatMessage } = useVIntl()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const diffs = computed<ContentDiffItem[]>(
() =>
preview.value?.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
disabled: diff.disabled,
external: diff.type === 'added' && !diff.projectId && !!diff.fileName,
})) ?? [],
preview.value?.diffs
// TODO: This is TEMP!!! Hashing needs to be done on backend
.filter((diff) => !(diff.type === 'updated' && !diff.projectId && diff.fileName))
.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
disabled: diff.disabled,
external: diff.type === 'added' && !diff.projectId && !!diff.fileName,
})) ?? [],
)
async function update() {
let successful = false
emit('accepted')
try {
if (instance.value) {
const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id)
await onComplete.value()
successful = true
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
@@ -82,7 +89,7 @@ async function update() {
}
notifySharedInstanceError(error)
} finally {
emit('complete')
emit('complete', successful)
}
}
@@ -64,7 +64,29 @@
</div>
</Admonition>
<p v-else class="m-0 text-primary">
{{ formatMessage(messages.inviteWarning) }}
<IntlFormatted
v-if="creator"
:message-id="messages.inviteWarningWithCreator"
:values="{ username: creator.username }"
>
<template #creator="{ children }">
<AutoLink :to="creatorProfileLink" class="font-medium text-contrast hover:underline">
<Avatar
:src="creator.avatarUrl"
:alt="creator.username"
:tint-by="creator.username"
size="24px"
circle
no-shadow
class="mr-1 inline-block align-middle"
/>
<span><component :is="() => children" /></span>
</AutoLink>
</template>
</IntlFormatted>
<template v-else>
{{ formatMessage(messages.inviteWarning) }}
</template>
</p>
<SharedInstanceInstallSummary
:preview="preview"
@@ -98,8 +120,17 @@
:max-height="240"
/>
</div>
<div v-if="reportOnly" class="flex flex-col gap-2">
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
<div v-if="reportOnly || blockTargetUserId" class="flex flex-col gap-2">
<Checkbox
v-if="reportOnly"
v-model="deleteInstance"
:label="formatMessage(messages.deleteInstance)"
/>
<Checkbox
v-if="blockTargetUserId"
v-model="blockUser"
:label="formatMessage(messages.blockUser)"
/>
</div>
</div>
</Transition>
@@ -162,53 +193,39 @@
{{ formatMessage(messages.reviewedFiles) }}
</p>
<div v-if="!reportMode" class="flex w-full items-center justify-between gap-2">
<ButtonStyled color="red" type="transparent">
<button @click="reportMode = true">
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<Button type="quiet" color="red" @click="reportMode = true">
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
</Button>
<div class="flex items-center gap-2">
<template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange">
<button @click="accept">
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleCancel">
<BanIcon />{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
<Button type="quiet" color="orange" @click="accept">
{{ formatMessage(messages.installAnyway) }}
</Button>
<Button type="colored" color="brand" @click="handleCancel">
<BanIcon />{{ formatMessage(messages.dontInstall) }}
</Button>
</template>
<template v-else>
<ButtonStyled type="outlined">
<button class="!border" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
<Button type="outlined" class="!border" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</Button>
</template>
</div>
</div>
</div>
<template v-if="reportMode" #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border" :disabled="submitLoading" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSubmitReport" @click="submitReport">
<SpinnerIcon v-if="submitLoading" class="animate-spin" />
<SendIcon v-else />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
<Button type="outlined" class="!border" :disabled="submitLoading" @click="handleCancel">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="!canSubmitReport" @click="submitReport">
<SpinnerIcon v-if="submitLoading" class="animate-spin" />
<SendIcon v-else />
{{ formatMessage(commonMessages.reportButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -223,15 +240,18 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import {
Admonition,
AutoLink,
ButtonStyled,
Avatar,
blockedUsersQueryKey,
Checkbox,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectAuth,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
@@ -243,12 +263,16 @@ import {
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue'
import { config } from '@/config'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { toError } from '@/helpers/errors'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import { create_report } from '@/helpers/reports'
import { block_user } from '@/helpers/users'
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
@@ -258,11 +282,17 @@ type ExternalFileRow = {
id: string
name: string
}
type SharedInstanceCreator = {
id: string | null
username: string
avatarUrl: string | null
}
const modal = ref<InstanceType<typeof NewModal>>()
const contentModal = ref<InstanceType<typeof ModpackContentModal>>()
const externalFileTable = ref<HTMLElement | null>(null)
const preview = ref<SharedInstanceInstallPreview | null>(null)
const creator = ref<SharedInstanceCreator | null>(null)
const install = ref<() => void | Promise<void>>(() => {})
const reportMode = ref(false)
const reportOnly = ref(false)
@@ -270,13 +300,17 @@ type ReportReason = 'malicious' | 'inappropriate' | 'spam'
const reportReason = ref<ReportReason>('malicious')
const additionalContext = ref('')
const deleteInstance = ref(true)
const blockUser = ref(true)
const blockTargetUserId = ref<string | null>(null)
const submitLoading = ref(false)
const uploadedImageIDs = ref<string[]>([])
const emit = defineEmits<{
reported: [deleteInstance: boolean]
}>()
const { formatMessage } = useVIntl()
const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification, handleError } = injectNotificationManager()
const { load } = useSharedInstancePreviewContent()
const {
@@ -302,6 +336,12 @@ const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [
const canSubmitReport = computed(
() => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value,
)
const creatorProfileLink = computed(() => {
const username = creator.value?.username
return username
? () => openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
: undefined
})
async function accept() {
hide()
@@ -329,13 +369,39 @@ async function submitReport() {
submitLoading.value = true
try {
const uploadedImages = uploadedImageIDs.value.slice(-10)
await create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
})
const blockTarget = blockUser.value ? blockTargetUserId.value : null
const [reportResult, blockResult] = await Promise.allSettled([
create_report({
report_type: reportReason.value,
item_type: 'shared-instance',
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
body,
uploaded_images: uploadedImages,
}),
blockTarget ? block_user(blockTarget) : Promise.resolve(),
])
if (blockTarget) {
if (blockResult.status === 'fulfilled') {
blockUser.value = false
const authUserId = auth.user.value?.id
if (authUserId) {
queryClient.setQueryData<Labrinth.BlockedUsers.v3.BlockedUserId[]>(
blockedUsersQueryKey(authUserId),
(blockedUsers = []) =>
blockedUsers.includes(blockTarget) ? blockedUsers : [...blockedUsers, blockTarget],
)
}
addNotification({
type: 'success',
title: formatMessage(messages.userBlocked),
})
} else {
handleError(toError(blockResult.reason))
}
}
if (reportResult.status === 'rejected') throw reportResult.reason
const shouldDeleteInstance = reportOnly.value && deleteInstance.value
hide()
@@ -381,6 +447,7 @@ function handleCancel() {
}
function handleHide() {
resetReportState()
creator.value = null
show_ads_window()
}
function resetReportState() {
@@ -389,20 +456,31 @@ function resetReportState() {
reportReason.value = 'malicious'
additionalContext.value = ''
deleteInstance.value = true
blockUser.value = true
blockTargetUserId.value = null
submitLoading.value = false
uploadedImageIDs.value = []
}
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
creatorValue?: SharedInstanceCreator,
event?: MouseEvent,
) {
resetReportState()
creator.value = creatorValue ?? null
blockTargetUserId.value = creatorValue?.id ?? null
install.value = installValue
showPreview(previewValue, event)
}
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
function showReport(
previewValue: SharedInstanceInstallPreview,
blockTargetUserIdValue?: string | null,
event?: MouseEvent,
) {
resetReportState()
creator.value = null
blockTargetUserId.value = blockTargetUserIdValue ?? null
reportMode.value = true
reportOnly.value = true
install.value = () => {}
@@ -437,6 +515,11 @@ const messages = defineMessages({
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
inviteWarningWithCreator: {
id: 'app.modal.install-to-play.invite-warning-with-creator',
defaultMessage:
'This invite was created by <creator>{username}</creator>, not Modrinth. Only accept invites from people you trust.',
},
reportDescription: {
id: 'app.modal.install-to-play.report-description',
defaultMessage:
@@ -492,6 +575,14 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.delete-instance',
defaultMessage: 'Delete instance',
},
blockUser: {
id: 'app.modal.install-to-play.block-user',
defaultMessage: 'Block user',
},
userBlocked: {
id: 'app.modal.install-to-play.user-blocked',
defaultMessage: 'User blocked',
},
unknownFilesWarning: {
id: 'app.modal.install-to-play.unknown-files-warning',
defaultMessage: 'Unknown files warning',
@@ -4,12 +4,10 @@
<span class="font-semibold text-contrast">{{
heading ?? formatMessage(messages.sharedInstance)
}}</span>
<ButtonStyled type="transparent">
<button @click="emit('viewContents')">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
<Button type="quiet" @click="emit('viewContents')">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</Button>
</div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
<Avatar
@@ -34,7 +32,7 @@
<script setup lang="ts">
import { EyeIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, defineMessages, formatLoader, useVIntl } from '@modrinth/ui'
import { Avatar, Button, defineMessages, formatLoader, useVIntl } from '@modrinth/ui'
import { computed, toRefs } from 'vue'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
@@ -1,7 +1,7 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem } from '@modrinth/ui'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
@@ -20,7 +20,18 @@ export function useSharedInstancePreviewContent() {
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
if (!version) return []
const [project, contentItems] = await Promise.all([
get_project(version.project_id, 'must_revalidate'),
contentItemsFromDependencies(version.dependencies ?? []),
])
if (!project) return contentItems
return contentItems.map((item) => ({
...item,
source: { project },
}))
}
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
@@ -28,6 +28,7 @@ const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModa
const {
handleNotification,
installFromInviteId,
clearNotifications,
handleAlreadyInstalledCancel,
handleAlreadyInstalledGoToInstance,
handleAlreadyInstalledInstallAnyway,
@@ -42,5 +43,6 @@ async function requestAuth(flow: ModrinthAuthFlow) {
defineExpose<SharedInstanceInviteHandler>({
handleNotification,
installFromInviteId,
clearNotifications,
})
</script>
@@ -26,4 +26,5 @@ export type SharedInstanceInvite = {
export type SharedInstanceInviteHandler = {
handleNotification(notification: AppNotification): Promise<boolean>
installFromInviteId(inviteId: string): Promise<void>
clearNotifications(): void
}
@@ -6,11 +6,9 @@ import {
injectPopupNotificationManager,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
@@ -19,6 +17,7 @@ import {
install_shared_instance,
} 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'
@@ -28,9 +27,16 @@ type InstallModal = {
show(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
): void
}
type SharedInstanceCreator = {
id: string | null
username: string
avatarUrl: string | null
}
type AccountRequiredModal = {
show(event?: MouseEvent): Promise<boolean>
}
@@ -47,16 +53,22 @@ export function useSharedInstanceInviteHandler(
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const { addPopupNotification } = injectPopupNotificationManager()
const { notifySharedInstanceConnectionError, notifySharedInstanceError } =
useSharedInstanceErrors()
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
const themeStore = useTheming()
const displayedNotifications = new Set<string | number>()
const displayedNotificationKeys = new Set<string>()
const popupNotificationIds = new Set<string | number>()
let notificationGeneration = 0
let pendingAlreadyInstalled:
| {
instanceId: string
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>
install: () => Promise<void>
creator?: SharedInstanceCreator
onGoToInstance?: () => void | Promise<void>
}
| undefined
@@ -72,10 +84,13 @@ export function useSharedInstanceInviteHandler(
async function resolveInvite(invite: SharedInstanceInvite) {
const [invitedBy, sharedInstance] = await Promise.all([
!invite.invitedByUsername && invite.invitedById
(!invite.invitedByUsername || !invite.invitedByAvatarUrl) && invite.invitedById
? get_user(invite.invitedById, 'bypass').catch(() => null)
: null,
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null),
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => {
notifySharedInstanceConnectionError()
return null
}),
])
return {
@@ -89,15 +104,17 @@ export function useSharedInstanceInviteHandler(
function showInstall(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
) {
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
installModal.value.show(preview, install)
installModal.value.show(preview, install, creator)
}
async function showInstallOrAlreadyInstalled(
sharedInstanceId: string,
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
creator?: SharedInstanceCreator,
onGoToInstance?: () => void | Promise<void>,
) {
const existingInstance = (await list()).find(
@@ -105,7 +122,7 @@ export function useSharedInstanceInviteHandler(
)
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
showInstall(preview, install)
showInstall(preview, install, creator)
return
}
@@ -117,6 +134,7 @@ export function useSharedInstanceInviteHandler(
instanceId: existingInstance.id,
preview,
install,
creator,
onGoToInstance,
}
alreadyInstalledModal.value.show(existingInstance.name)
@@ -145,7 +163,7 @@ export function useSharedInstanceInviteHandler(
const pending = pendingAlreadyInstalled
pendingAlreadyInstalled = undefined
if (!pending) return
showInstall(pending.preview, pending.install)
showInstall(pending.preview, pending.install, pending.creator)
}
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
@@ -171,10 +189,17 @@ export function useSharedInstanceInviteHandler(
await markNotificationRead(notification)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
invite.invitedByUsername
? {
id: invite.invitedById,
username: invite.invitedByUsername,
avatarUrl: invite.invitedByAvatarUrl,
}
: undefined,
() => markNotificationRead(notification),
)
} catch (error) {
handleError(toError(error))
notifySharedInstanceError(error)
}
}
@@ -183,9 +208,23 @@ export function useSharedInstanceInviteHandler(
if (!parsedInvite) return false
if (displayedNotifications.has(notification.id)) return true
const generation = notificationGeneration
displayedNotifications.add(notification.id)
const invite = await resolveInvite(parsedInvite)
addPopupNotification({
if (generation !== notificationGeneration) return true
const notificationKey = JSON.stringify([
invite.invitedById ?? invite.invitedByUsername,
invite.sharedInstanceName,
invite.instanceIconUrl,
])
if (displayedNotificationKeys.has(notificationKey)) {
await markNotificationRead(notification).catch((error) => handleError(toError(error)))
return true
}
displayedNotificationKeys.add(notificationKey)
const popupNotification = popupNotificationManager.addPopupNotification({
title: invite.sharedInstanceName,
autoCloseMs: null,
toast: {
@@ -199,14 +238,26 @@ export function useSharedInstanceInviteHandler(
markNotificationRead(notification).catch((error) => handleError(toError(error))),
onOpenActor: () => {
if (invite.invitedByUsername) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
void router.push(`/user/${encodeURIComponent(invite.invitedByUsername)}`)
}
},
},
})
popupNotificationIds.add(popupNotification.id)
return true
}
function clearNotifications() {
notificationGeneration++
for (const id of popupNotificationIds) {
popupNotificationManager.removeNotification(id)
}
displayedNotifications.clear()
displayedNotificationKeys.clear()
popupNotificationIds.clear()
pendingAlreadyInstalled = undefined
}
async function requireAccount() {
if (!auth.isReady?.value) {
await new Promise<void>((resolve) => {
@@ -226,25 +277,40 @@ export function useSharedInstanceInviteHandler(
try {
if (!(await requireAccount())) return
const invite = await install_accept_shared_instance_invite(inviteId)
await showInstallOrAlreadyInstalled(invite.sharedInstanceId, invite.preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
const manager = invite.managerId
? await get_user(invite.managerId, 'bypass').catch(() => null)
: null
await showInstallOrAlreadyInstalled(
invite.sharedInstanceId,
invite.preview,
async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
},
manager
? {
id: manager.id,
username: manager.username,
avatarUrl: manager.avatar_url ?? null,
}
: undefined,
)
} catch (error) {
handleError(toError(error))
notifySharedInstanceError(error)
}
}
return {
handleNotification,
installFromInviteId,
clearNotifications,
handleAlreadyInstalledCancel,
handleAlreadyInstalledGoToInstance,
handleAlreadyInstalledInstallAnyway,
@@ -22,11 +22,9 @@
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section v-if="mode === 'edit' && canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
<ButtonStyled>
<button class="!shadow-none" @click="openTextureFileBrowser">
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
</button>
</ButtonStyled>
<Button @click="openTextureFileBrowser">
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
</Button>
<input
ref="textureFileInput"
type="file"
@@ -112,19 +110,21 @@
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button :disabled="isSaving" @click="hide">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
</button>
</ButtonStyled>
<Button type="outlined" :disabled="isSaving" @click="hide">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
v-tooltip="saveTooltip"
type="colored"
color="brand"
:disabled="disableSave || isSaving"
@click="save"
>
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -133,7 +133,7 @@
<script setup lang="ts">
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
CapeButton,
CapeLikeTextButton,
commonMessages,
@@ -2,9 +2,10 @@
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
Button,
commonMessages,
defineMessages,
IconButton,
SkinButton,
SkinLikeTextButton,
useScrollViewport,
@@ -463,25 +464,26 @@ defineExpose({ getAddSkinButtonElement })
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</Button>
<IconButton
v-show="!skin.is_equipped"
v-tooltip="formatMessage(messages.deleteSkinButton)"
type="colored"
color="red"
:label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</IconButton>
</template>
</SkinButton>
</div>
@@ -503,25 +505,26 @@ defineExpose({ getAddSkinButtonElement })
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</Button>
<IconButton
v-show="!skin.is_equipped"
v-tooltip="formatMessage(messages.deleteSkinButton)"
type="colored"
color="red"
:label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</IconButton>
</template>
</SkinButton>
</div>
@@ -545,15 +548,15 @@ defineExpose({ getAddSkinButtonElement })
@select="emit('select', skin)"
>
<template #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<Button
type="colored"
color="brand"
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</Button>
</template>
</SkinButton>
</div>
@@ -9,11 +9,11 @@ import {
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Button,
commonMessages,
injectNotificationManager,
OverflowMenu,
SmartClickable,
TeleportOverflowMenu,
useFormatDateTime,
useRelativeTime,
useVIntl,
@@ -185,54 +185,53 @@ onUnmounted(() => {
</div>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled v-if="playing && !loading" color="red">
<button @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
instance.quarantined
? 'This instance has been locked'
: playing
? 'Instance is already open'
: null
"
:disabled="instance.quarantined || playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'open-instance',
shown: !!instance.id,
action: () => router.push(encodeURI(`/instance/${instance.id}`)),
},
{
id: 'open-folder',
action: () => showInstanceInFolder(instance.id),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-instance>
<EyeIcon aria-hidden="true" />
View instance
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
<Button v-if="playing && !loading" type="colored" color="red" @click="stop">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</Button>
<Button
v-else
v-tooltip="
instance.quarantined
? 'This instance has been locked'
: playing
? 'Instance is already open'
: null
"
:disabled="instance.quarantined || playing || loading"
@click="play"
>
<SpinnerIcon v-if="loading" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<TeleportOverflowMenu
type="quiet"
label="More options"
:options="[
{
id: 'open-instance',
label: 'View instance',
shown: !!instance.id,
action: () => router.push(encodeURI(`/instance/${instance.id}`)),
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
action: () => showInstanceInFolder(instance.id),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-instance>
<EyeIcon aria-hidden="true" />
View instance
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
</TeleportOverflowMenu>
</div>
</div>
</SmartClickable>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import type { GameVersion } from '@modrinth/ui'
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { GAME_MODES, injectNotificationManager } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
@@ -268,13 +268,7 @@ onUnmounted(() => {
</div>
</div>
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in
</HeadingLink>
<span
v-else
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
>
<span class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold">
Jump back in
</span>
<div class="grid-when-huge flex flex-col w-full gap-2">
@@ -21,13 +21,13 @@ import {
import type { MessageDescriptor } from '@modrinth/ui'
import {
Avatar,
ButtonStyled,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
OverflowMenu,
SmartClickable,
TagItem,
TeleportOverflowMenu,
useFormatDateTime,
useFormatNumber,
useRelativeTime,
@@ -412,177 +412,184 @@ const messages = defineMessages({
</template>
</div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled
<Button
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
type="colored"
color="red"
@click="emit('stop')"
>
<button @click="emit('stop')">
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
quarantined
? 'This instance has been locked'
: world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="
quarantined ||
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{
id: 'play-instance',
shown: !!instanceId,
disabled: playingInstance || quarantined,
action: () => emit('play-instance'),
<StopCircleIcon aria-hidden="true" />
{{ formatMessage(commonMessages.stopButton) }}
</Button>
<Button
v-else
v-tooltip="
quarantined
? 'This instance has been locked'
: world.type === 'server'
? !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: playingOtherWorld
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
: !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="
quarantined ||
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</Button>
<TeleportOverflowMenu
type="quiet"
label="More options"
:options="[
{
id: 'play-instance',
label: formatMessage(messages.playInstance),
shown: !!instanceId,
disabled: playingInstance || quarantined,
action: () => emit('play-instance'),
},
{
id: 'open-instance',
label: formatMessage(messages.viewInstance),
shown: !!instanceId,
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
},
{
id: 'refresh',
label: formatMessage(commonMessages.refreshButton),
shown: world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
label: formatMessage(messages.copyAddress),
shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address),
},
{
id: 'edit',
label: formatMessage(commonMessages.editButton),
action: () => emit('edit'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
},
{
type: 'divider',
shown: !!instanceId,
},
{
id: 'dont-show-on-home',
label: formatMessage(messages.dontShowOnHome),
shown: !!instanceId,
action: () => {
set_world_display_status(
instanceId,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
{
id: 'open-instance',
shown: !!instanceId,
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
},
{
id: 'refresh',
shown: world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address),
},
{
id: 'edit',
action: () => emit('edit'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
{
id: 'open-folder',
shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
},
{
divider: true,
shown: !!instanceId,
},
{
id: 'dont-show-on-home',
shown: !!instanceId,
action: () => {
set_world_display_status(
instanceId,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
},
{
id: 'create-shortcut',
shown: !!shortcutInstanceId && !quarantined,
action: () => createShortcut(),
},
{
divider: true,
shown: !instanceId,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => emit('delete'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #edit>
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy-address>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(messages.copyAddress) }}
</template>
<template #refresh>
<UpdatedIcon aria-hidden="true" />
{{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #create-shortcut>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.createShortcut) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{
formatMessage(
world.type === 'server'
? commonMessages.removeButton
: commonMessages.deleteLabel,
)
}}
</template>
</OverflowMenu>
</ButtonStyled>
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
shown: !!shortcutInstanceId && !quarantined,
action: () => createShortcut(),
},
{
type: 'divider',
shown: !instanceId,
},
{
id: 'delete',
label: formatMessage(
world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
),
tone: 'red',
action: () => emit('delete'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #edit>
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy-address>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(messages.copyAddress) }}
</template>
<template #refresh>
<UpdatedIcon aria-hidden="true" />
{{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #create-shortcut>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.createShortcut) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{
formatMessage(
world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
)
}}
</template>
</TeleportOverflowMenu>
</div>
</div>
</SmartClickable>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
@@ -96,24 +96,18 @@ defineExpose({ show, hide })
/>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="!address" @click="addServer(false)">
<PlusIcon />
{{ formatMessage(messages.addServer) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!address" @click="addServer(true)">
<PlayIcon />
{{ formatMessage(messages.addAndPlay) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button :disabled="!address" @click="addServer(false)">
<PlusIcon />
{{ formatMessage(messages.addServer) }}
</Button>
<Button type="colored" color="brand" :disabled="!address" @click="addServer(true)">
<PlayIcon />
{{ formatMessage(messages.addAndPlay) }}
</Button>
</div>
</template>
</NewModal>
@@ -2,7 +2,7 @@
import { TrashIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Button,
commonMessages,
defineMessages,
NewModal,
@@ -106,18 +106,19 @@ defineExpose({ show, hide })
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="!isServer && !isSingleplayer" @click="confirm">
<TrashIcon />
{{ formatMessage(actionMessage) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="red"
:disabled="!isServer && !isSingleplayer"
@click="confirm"
>
<TrashIcon />
{{ formatMessage(actionMessage) }}
</Button>
</div>
</template>
</NewModal>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Button,
commonMessages,
defineMessage,
injectNotificationManager,
@@ -105,18 +105,14 @@ const titleMessage = defineMessage({
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<Button type="outlined" @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="!address" @click="saveServer">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
</div>
</template>
</NewModal>
@@ -2,7 +2,7 @@
import { ChevronRightIcon, SaveIcon, UndoIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
@@ -113,24 +113,18 @@ const messages = defineMessages({
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button @click="saveWorld">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="removeIcon || !icon" @click="removeIcon = true">
<UndoIcon />
{{ formatMessage(messages.resetIcon) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<Button type="colored" color="brand" @click="saveWorld">
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
<Button :disabled="removeIcon || !icon" @click="removeIcon = true">
<UndoIcon />
{{ formatMessage(messages.resetIcon) }}
</Button>
<Button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
</ModalWrapper>
</template>
@@ -23,6 +23,7 @@ import {
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
import { useTheming } from '@/store/state'
const messages = defineMessages({
installs: {
@@ -221,13 +222,6 @@ const failureSummaryMessages = defineMessages({
})
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
const copyDetailsStallMs = 30_000
interface ProgressSnapshot {
signature: string
changedAt: number
timeout: number | null
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
@@ -241,6 +235,7 @@ export async function useInstallJobNotifications(opts: {
onChange: () => void
}) {
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
@@ -250,7 +245,6 @@ export async function useInstallJobNotifications(opts: {
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
const progressSnapshots = new Map<string, ProgressSnapshot>()
function getTitle(job: InstallJobSnapshot): string {
if (job.display?.title) return job.display.title
@@ -421,116 +415,14 @@ export async function useInstallJobNotifications(opts: {
return job.status === 'failed' || job.status === 'interrupted'
}
function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
return (
job.status === 'running' &&
job.phase !== 'preparing_instance' &&
job.phase !== 'finalizing' &&
job.phase !== 'rolling_back'
)
}
function getJobSortRank(job: InstallJobSnapshot): number {
if (isTerminalJob(job)) return 0
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
return 1
}
function progressSignature(job: InstallJobSnapshot): string {
const progress = job.progress
const secondary = progress?.secondary
return [
job.status,
job.phase,
JSON.stringify(job.details),
progress?.current ?? '',
progress?.total ?? '',
secondary?.current ?? '',
secondary?.total ?? '',
].join(':')
}
function clearCopied(jobId: string) {
if (!copiedJobIds.value.has(jobId)) {
return
}
const timeout = copiedResetTimeouts.get(jobId)
if (timeout != null) {
window.clearTimeout(timeout)
copiedResetTimeouts.delete(jobId)
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(jobId)
copiedJobIds.value = nextCopiedJobIds
}
function clearProgressSnapshot(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (snapshot?.timeout != null) {
window.clearTimeout(snapshot.timeout)
}
progressSnapshots.delete(jobId)
}
function scheduleStaleProgressRefresh(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = window.setTimeout(() => {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = null
opts.onChange()
}, copyDetailsStallMs)
}
function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) {
const trackedJobIds = new Set<string>()
const now = Date.now()
for (const job of nextJobs) {
if (!canShowStalledProgressDetails(job)) {
continue
}
trackedJobIds.add(job.job_id)
const signature = progressSignature(job)
const snapshot = progressSnapshots.get(job.job_id)
if (snapshot?.signature === signature) {
continue
}
clearProgressSnapshot(job.job_id)
clearCopied(job.job_id)
progressSnapshots.set(job.job_id, {
signature,
changedAt: now,
timeout: null,
})
scheduleStaleProgressRefresh(job.job_id)
}
for (const jobId of progressSnapshots.keys()) {
if (!trackedJobIds.has(jobId)) {
clearProgressSnapshot(jobId)
}
}
}
function hasStalledProgress(job: InstallJobSnapshot): boolean {
const snapshot = progressSnapshots.get(job.job_id)
return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isTerminalJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job))
return isTerminalJob(job) || themeStore.getFeatureFlag('always_show_copy_details')
}
function isCopied(job: InstallJobSnapshot): boolean {
@@ -607,6 +499,16 @@ export async function useInstallJobNotifications(opts: {
return buttons
}
function getDismissHandler(job: InstallJobSnapshot): (() => Promise<void>) | undefined {
if (isTerminalJob(job)) {
return async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
}
return undefined
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
@@ -615,7 +517,6 @@ export async function useInstallJobNotifications(opts: {
}
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(
(a, b) =>
@@ -642,12 +543,8 @@ export async function useInstallJobNotifications(opts: {
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getButtons(job),
onDismiss: isTerminalJob(job)
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
: undefined,
dismissible: isTerminalJob(job),
onDismiss: getDismissHandler(job),
}
}),
)
@@ -730,8 +627,8 @@ export async function useInstallJobNotifications(opts: {
void refreshMetadata()
}
await refresh(false)
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job))
await refresh(false)
return {
active: computed(() => jobs.value.length > 0),
@@ -743,9 +640,6 @@ export async function useInstallJobNotifications(opts: {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
for (const jobId of progressSnapshots.keys()) {
clearProgressSnapshot(jobId)
}
unlisten()
},
}
+2 -2
View File
@@ -16,8 +16,8 @@ export async function hide_ads_window(reset) {
return await invoke('plugin:ads|hide_ads_window', { reset })
}
export async function get_ads_consent_required() {
return await invoke('plugin:ads|get_ads_consent_required')
export async function should_show_ads_consent_popup() {
return await invoke('plugin:ads|should_show_ads_consent_popup')
}
export async function perform_ads_consent_action(action) {
+5
View File
@@ -102,6 +102,7 @@ export interface SharedInstanceUpdateDiff {
}
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export const SHARED_INSTANCES_API_ERROR_CODE = 'shared_instances_api_error'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined'
@@ -113,6 +114,10 @@ export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null
}
export function isSharedInstancesApiError(error: unknown) {
return isRecord(error) && error.code === SHARED_INSTANCES_API_ERROR_CODE
}
export function getSharedInstanceUnavailableReason(
error: unknown,
): SharedInstanceUnavailableReason | null {
+20
View File
@@ -365,6 +365,13 @@ export interface SharedInstanceInviteLink {
maxUses: number
}
export interface SharedInstanceInvite {
id: string
expiration: string
maxUses: number
uses: number
}
export async function can_current_user_use_shared_instances(): Promise<boolean> {
return await invoke('plugin:instance|instance_share_can_current_user_use')
}
@@ -394,6 +401,19 @@ export async function create_shared_instance_invite_link(
})
}
export async function get_shared_instance_invites(
instanceId: string,
): Promise<SharedInstanceInvite[]> {
return await invoke('plugin:instance|instance_share_get_invites', { instanceId })
}
export async function revoke_shared_instance_invite(
instanceId: string,
inviteId: string,
): Promise<void> {
return await invoke('plugin:instance|instance_share_revoke_invite', { instanceId, inviteId })
}
export async function remove_shared_instance_users(
instanceId: string,
userIds: string[],
+1 -1
View File
@@ -37,7 +37,7 @@ export type AppSettings = {
theme: ColorTheme
locale: string
default_page: 'home' | 'library'
default_page: 'Home' | 'Library'
collapsed_navigation: boolean
hide_nametag_skins_page: boolean
advanced_rendering: boolean
@@ -1,6 +1,10 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install'
import {
getErrorMessage,
isSharedInstancesApiError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
export const sharedInstanceErrorMessages = defineMessages({
unavailableTitle: {
@@ -39,6 +43,14 @@ export const sharedInstanceErrorMessages = defineMessages({
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
networkErrorTitle: {
id: 'instance.shared-instance.network-error.title',
defaultMessage: 'Network error',
},
networkErrorText: {
id: 'instance.shared-instance.network-error.text',
defaultMessage: 'Unable to connect to shared instances API',
},
})
export function sharedInstanceUnavailableTextMessage(
@@ -82,7 +94,20 @@ export function useSharedInstanceErrors() {
})
}
function notifySharedInstanceConnectionError() {
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.networkErrorTitle),
text: formatMessage(sharedInstanceErrorMessages.networkErrorText),
})
}
function notifySharedInstanceError(error: unknown) {
if (isSharedInstancesApiError(error)) {
notifySharedInstanceConnectionError()
return
}
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.errorTitle),
@@ -92,6 +117,7 @@ export function useSharedInstanceErrors() {
return {
formatSharedInstanceUnavailable,
notifySharedInstanceConnectionError,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
}
+1 -1
View File
@@ -189,7 +189,7 @@ type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: 'dark' | 'light' | 'oled'
theme: 'dark' | 'light' | 'oled' | 'retro' | 'system'
default_page: 'Home' | 'Library'
collapsed_navigation: boolean
advanced_rendering: boolean
+75 -6
View File
@@ -1,11 +1,80 @@
import type { Labrinth } from '@modrinth/api-client'
import { invoke } from '@tauri-apps/api/core'
export type SearchUser = {
id: string
username: string
avatar_url: string | null
// Converts user profile links from rendered Markdown/any dynamic content into app routes.
export function parse_modrinth_user_link(href: string): string | null {
try {
const url = new URL(href)
if (url.hostname !== 'modrinth.com' && url.hostname !== 'www.modrinth.com') return null
const segments = url.pathname.split('/').filter(Boolean)
if (segments[0]?.toLowerCase() !== 'user' || !segments[1] || segments.length > 3) return null
const path = `/user/${encodeURIComponent(decodeURIComponent(segments[1]))}`
return segments[2] ? `${path}/${encodeURIComponent(decodeURIComponent(segments[2]))}` : path
} catch {
return null
}
}
export async function search_user(query: string): Promise<SearchUser[]> {
return await invoke<SearchUser[]>('plugin:users|search_user', { query })
export async function search_user(query: string): Promise<Labrinth.Users.v3.SearchUser[]> {
return await invoke<Labrinth.Users.v3.SearchUser[]>('plugin:users|search_user', { query })
}
export async function get_user_profile(userId: string): Promise<Labrinth.Users.v3.User> {
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', {
userId,
})
}
export async function get_user_organizations(
userId: string,
): Promise<Labrinth.Organizations.v3.Organization[]> {
return await invoke<Labrinth.Organizations.v3.Organization[]>(
'plugin:users|get_user_organizations',
{ userId },
)
}
export async function get_user_collections(
userId: string,
): Promise<Labrinth.Collections.Collection[]> {
return await invoke<Labrinth.Collections.Collection[]>('plugin:users|get_user_collections', {
userId,
})
}
export async function patch_user(
userId: string,
patch: Partial<Pick<Labrinth.Users.v2.User, 'badges' | 'bio' | 'role' | 'username'>>,
): Promise<void> {
await invoke('plugin:users|patch_user', { userId, patch })
}
export async function change_user_avatar(
userId: string,
image: Uint8Array,
extension: string,
): Promise<void> {
await invoke('plugin:users|change_user_avatar', { userId, image, extension })
}
export async function delete_user_avatar(userId: string): Promise<void> {
await invoke('plugin:users|delete_user_avatar', { userId })
}
export async function block_user(userId: string): Promise<void> {
await invoke('plugin:users|block_user', { userId })
}
export async function unblock_user(userId: string): Promise<void> {
await invoke('plugin:users|unblock_user', { userId })
}
export async function get_blocked_users(): Promise<Labrinth.BlockedUsers.v3.BlockedUserId[]> {
return await invoke<Labrinth.BlockedUsers.v3.BlockedUserId[]>('plugin:users|get_blocked_users')
}
+48 -9
View File
@@ -125,6 +125,15 @@
"app.action-bar.view-logs": {
"message": "عرض التسجيل"
},
"app.ads-consent.accept": {
"message": "قبول الكل"
},
"app.ads-consent.manage": {
"message": "إدارة التفضيلات"
},
"app.ads-consent.reject": {
"message": "رفض الكل"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "يتيح العرض المتقدم لتأثيرات مثل التشويش الذي قد تسبب مشكلات في الأداء دون استخدام العرض المسرع بالأجهزة."
},
@@ -173,9 +182,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "الزخارف التقليدية"
},
"app.appearance-settings.select-option": {
"message": "حدد خياراً"
},
"app.appearance-settings.show-play-time.description": {
"message": "يعرض مقدار الوقت الذي قضيته في لعب إحدى المهام."
},
@@ -227,9 +233,6 @@
"app.browse.back-to-instance": {
"message": "العودة للنموذج"
},
"app.browse.discover-content": {
"message": "استكشف محتوى"
},
"app.browse.discover-servers": {
"message": "استكشف خوادم"
},
@@ -428,24 +431,33 @@
"app.modal.install-to-play.header": {
"message": "نزل للعب"
},
"app.modal.install-to-play.install-anyway": {
"message": "تثبيت على أي حال"
},
"app.modal.install-to-play.install-button": {
"message": "تثبيت"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# تعديل} other {# تعديلات}}"
},
"app.modal.install-to-play.reviewed-files": {
"message": "لا يتم مراجعة الملف إلا إذا تم نشره على Modrinth، بغض النظر عن صيغته (بما في ذلك .mrpack)."
},
"app.modal.install-to-play.shared-instance": {
"message": "النماذج المشتركة"
},
"app.modal.install-to-play.unknown-files-warning": {
"message": "تحذير ملفات غير معروفة"
},
"app.modal.install-to-play.unrecognized-files": {
"message": "ملفات غير متعرّف عليها"
},
"app.modal.install-to-play.view-contents": {
"message": "عرض المحتويات"
},
"app.modal.update-to-play.header": {
"message": "حدث للعب"
},
"app.modal.update-to-play.update-required-description": {
"message": "هناك تحديث مطلوب للعب بـ {name}. الرجاء التحديث إلى أحدث اصدار لتشغيل اللعبة."
},
"app.project.install-button.already-installed": {
"message": "هذا المشروع مُثبَّت بالفعل"
},
@@ -641,6 +653,12 @@
"app.skins.title": {
"message": "اختيار المظهر"
},
"app.skins.toggle-ears-features-off": {
"message": "إيقاف"
},
"app.skins.toggle-ears-features-on": {
"message": "تشغيل"
},
"app.update-popup.body.download-complete": {
"message": "انتهى تنزيل تطبيق Modrinth v{version}. أعد التحميل للتحديث الآن، أو تلقائيًا عند إغلاق تطبيق Modrinth."
},
@@ -752,6 +770,24 @@
"friends.sign-in-to-add-friends": {
"message": "<link>سجّل الدخول إلى حساب Modrinth</link> لإضافة الأصدقاء ومعرفة ما الذي يلعبونه!"
},
"instance.action.create-shortcut": {
"message": "إنشاء اختصار"
},
"instance.action.more-actions": {
"message": "مزيد من الخيارات"
},
"instance.action.open-folder": {
"message": "فتح المجلد"
},
"instance.action.repair": {
"message": "إصلاح"
},
"instance.action.starting": {
"message": "قيد التشغيل..."
},
"instance.action.stopping": {
"message": "قيد الإيقاف..."
},
"instance.add-server.add-and-play": {
"message": "إضافة واللعب"
},
@@ -794,6 +830,9 @@
"instance.files.save-as": {
"message": "حفظ ك..."
},
"instance.playtime.never-played": {
"message": "لم تُلعب من قبل"
},
"instance.server-modal.address": {
"message": "العنوان"
},
@@ -113,9 +113,6 @@
"app.appearance-settings.native-decorations.title": {
"message": "Systémové dekorace"
},
"app.appearance-settings.select-option": {
"message": "Vyberte možnost"
},
"app.appearance-settings.show-play-time.description": {
"message": "Zobrazuje, kolik času jsi strávil hraním instance."
},
@@ -167,9 +164,6 @@
"app.browse.back-to-instance": {
"message": "Zpět k instanci"
},
"app.browse.discover-content": {
"message": "Objevit obsah"
},
"app.browse.discover-servers": {
"message": "Objevit servery"
},
@@ -380,9 +374,6 @@
"app.modal.update-to-play.header": {
"message": "Aktualizuj pro hraní"
},
"app.modal.update-to-play.update-required-description": {
"message": "Pro hraní {name} je vyžadována aktualizace. Prosím aktualizuj na nejnovější verzi, abys mohl hru spustit."
},
"app.project.install-button.already-installed": {
"message": "Tento projekt je již nainstalován"
},
+281 -8
View File
@@ -11,6 +11,12 @@
"app.action-bar.hide-more-running-instances": {
"message": "Gem flere kørende instances"
},
"app.action-bar.install.copied-details": {
"message": "Kopiere"
},
"app.action-bar.install.copy-details": {
"message": "Kopier detaljer"
},
"app.action-bar.install.dismiss": {
"message": "Tilsidesæt"
},
@@ -20,9 +26,69 @@
"app.action-bar.install.retry": {
"message": "Prøv igen"
},
"app.action-bar.install.summary.app-closing": {
"message": "Annulleret på grund af appen blev lukket"
},
"app.action-bar.install.summary.bad-modpack-file": {
"message": "Kunne ikke indlæse modpack"
},
"app.action-bar.install.summary.canceled": {
"message": "Annulleret"
},
"app.action-bar.install.summary.content-download-failed": {
"message": "Kunne ikke download filer"
},
"app.action-bar.install.summary.corrupt-download": {
"message": "Downloaded filer er korrupt"
},
"app.action-bar.install.summary.could-not-save-files": {
"message": "Kunne ikke gemme filer"
},
"app.action-bar.install.summary.download-failed": {
"message": "Download kunne ikke blive fuldført"
},
"app.action-bar.install.summary.instance-not-found": {
"message": "Instance kunne ikke blive fundet"
},
"app.action-bar.install.summary.invalid-file-path": {
"message": "Fil sti er ugyldig"
},
"app.action-bar.install.summary.invalid-modpack": {
"message": "Modpack data ugyldig"
},
"app.action-bar.install.summary.invalid-modpack-files": {
"message": "Modpack har ugyldig metadata"
},
"app.action-bar.install.summary.java-setup-failed": {
"message": "Java setup kunne ikke færdiggøres"
},
"app.action-bar.install.summary.loader-setup-failed": {
"message": "Loader setup fejlede"
},
"app.action-bar.install.summary.local-data-error": {
"message": "Kunne ikke opdatere lokal data"
},
"app.action-bar.install.summary.minecraft-setup-failed": {
"message": "Minecraft setup fejlede"
},
"app.action-bar.install.summary.modrinth-unreachable": {
"message": "Kunne ikke få forbindelse til Modrinth"
},
"app.action-bar.install.summary.no-write-permission": {
"message": "Ingen adgang til at skrive"
},
"app.action-bar.install.summary.pack-download-failed": {
"message": "Kunne ikke download pack"
},
"app.action-bar.install.summary.unexpected-error": {
"message": "Noget gik galt"
},
"app.action-bar.install.unknown-instance": {
"message": "Ukendt instance"
},
"app.action-bar.install.updating-shared-content": {
"message": "Opdatere delt indhold"
},
"app.action-bar.make-primary-instance": {
"message": "Gør til primære instance"
},
@@ -56,6 +122,15 @@
"app.action-bar.view-logs": {
"message": "Vis logs"
},
"app.ads-consent.accept": {
"message": "Tillad alle"
},
"app.ads-consent.body": {
"message": "Reklamer gør Modrinth muligt og finansiere skaber udbetalinger. Vores partnere må gemme og tilgå cookies i appen for at personliggøre reklamer og måle effektivitet."
},
"app.ads-consent.reject": {
"message": "Afvis alle"
},
"app.appearance-settings.advanced-rendering.description": {
"message": "Aktiverer avanceret gengivelse som sløringseffekter, der kan forårsage ydelsesproblemer uden hardwareaccelereret gengivelse."
},
@@ -98,9 +173,6 @@
"app.appearance-settings.minimize-launcher.title": {
"message": "Minimer launcher"
},
"app.appearance-settings.select-option": {
"message": "Vælg en mulighed"
},
"app.appearance-settings.show-play-time.description": {
"message": "Vis hvor meget tid du har brugt på at spille en instance."
},
@@ -146,9 +218,6 @@
"app.browse.back-to-instance": {
"message": "Tilbage til instance"
},
"app.browse.discover-content": {
"message": "Opdag indhold"
},
"app.browse.discover-servers": {
"message": "Opdag servere"
},
@@ -197,6 +266,9 @@
"app.install.phase.preparing_java.downloading": {
"message": "Downloader Java {version}"
},
"app.instance.admonitions.shared-instance.added-label": {
"message": "Tilføjet"
},
"app.instance.confirm-delete.admonition-body": {
"message": "Al' data for din instance vil blive permanent slettet, dette inkludere dine verdener, konfigurationer, og alt installeret indhold."
},
@@ -242,12 +314,51 @@
"app.instance.mods.successfully-uploaded": {
"message": "Uploadet"
},
"app.instance.share.locked.empty-description-suffix": {
"message": "for at tilgå denne side."
},
"app.instance.share.locked.signed-out-heading": {
"message": "Ikke logget ind"
},
"app.instance.share.locked.switch-account-button": {
"message": "Skift konto"
},
"app.instance.share.locked.wrong-account-heading": {
"message": "Forkert konto"
},
"app.instance.share.remove-user-modal.effects-label": {
"message": "Hvad vil der ske?"
},
"app.instance.share.remove-user-modal.user-avatar-alt": {
"message": "{username}'s avatar"
},
"app.instance.share.sign-in.button": {
"message": "Log ind"
},
"app.instance.shared-instance-already-installed.install-anyway": {
"message": "Installer alligevel"
},
"app.instance.shared-instance-already-installed.instance": {
"message": "Instans"
},
"app.instance.shared-instance-wrong-account.sign-in-as-label": {
"message": "Log ind som"
},
"app.instance.worlds.add-server": {
"message": "Tilføj server"
},
"app.instance.worlds.browse-servers": {
"message": "Søg servere"
},
"app.instance.worlds.delete-world-modal.delete-button": {
"message": "Slet verden"
},
"app.instance.worlds.delete-world-modal.title": {
"message": "Slet verden"
},
"app.instance.worlds.delete-world-modal.warning-header": {
"message": "Sletter {name}"
},
"app.instance.worlds.filter-modded": {
"message": "Moddet"
},
@@ -266,18 +377,42 @@
"app.instance.worlds.no-worlds-heading": {
"message": "Ingen servere eller verdener tilføjet"
},
"app.instance.worlds.remove-server-modal.remove-button": {
"message": "Fjern server"
},
"app.instance.worlds.remove-server-modal.title": {
"message": "Fjern server"
},
"app.instance.worlds.remove-server-modal.warning-header": {
"message": "Fjerner {name}"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Søg {count} verdener..."
},
"app.modal.install-to-play.additional-context": {
"message": "Yderligere kontekst"
},
"app.modal.install-to-play.delete-instance": {
"message": "Slet instans"
},
"app.modal.install-to-play.header": {
"message": "Installer for at spille"
},
"app.modal.install-to-play.install-anyway": {
"message": "Installer alligevel"
},
"app.modal.install-to-play.install-button": {
"message": "Installer"
},
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.report-image-too-large": {
"message": "Filen overskrider 1MiB størrelse begrænsning"
},
"app.modal.install-to-play.report-reason.spam": {
"message": "Spam"
},
"app.modal.install-to-play.shared-instance": {
"message": "Delt instance"
},
@@ -287,15 +422,33 @@
"app.modal.update-to-play.header": {
"message": "Opdater for at spille"
},
"app.modal.update-to-play.update-required-description": {
"message": "En opdatering er krævet for at spille {name}. Venligst opdater til den seneste version for at køre spillet."
"app.modal.update-to-play.shared-instance-added-label": {
"message": "Tilføjede"
},
"app.modal.update-to-play.shared-instance-removed-label": {
"message": "Fjernede"
},
"app.modal.update-to-play.update-required": {
"message": "Opdatering krævet"
},
"app.project.install-button.already-installed": {
"message": "Dette projekt er allerede installeret"
},
"app.project.install-button.switch-version": {
"message": "Skift version"
},
"app.project.install-context.back-to-browse": {
"message": "Tilbage til udforsk"
},
"app.project.version.all-versions": {
"message": "Alle versioner"
},
"app.project.version.download-in-browser": {
"message": "Download i browser"
},
"app.project.version.installing": {
"message": "Installere"
},
"app.project.versions.already-installed": {
"message": "Allerede installeret"
},
@@ -356,6 +509,9 @@
"app.skins.modal.add-title": {
"message": "Tilføjer et skin"
},
"app.skins.modal.arm-style-section": {
"message": "Arm stil"
},
"app.skins.modal.arm-style-slim": {
"message": "Slank"
},
@@ -458,6 +614,12 @@
"app.skins.title": {
"message": "Skin vælger"
},
"app.skins.toggle-ears-features-off": {
"message": "Slå fra"
},
"app.skins.toggle-ears-features-on": {
"message": "Slå til"
},
"app.update-popup.body.download-complete": {
"message": "Modrinth App v{version} er færdig med at download. Genindlæs for at opdatere nu, eller automatisk når du lukker Modrinth App."
},
@@ -506,6 +668,9 @@
"app.world.world-item.players-online": {
"message": "{count} online"
},
"content.shared-instance.delete-button": {
"message": "Slet alligevel"
},
"friends.action.add-friend": {
"message": "Tilføj en ven"
},
@@ -569,6 +734,27 @@
"friends.sign-in-to-add-friends": {
"message": "<link>Log ind på en Modrinth konto</link> for at tilføje venner og se hvad de spiller!"
},
"instance.action.create-shortcut": {
"message": "Opret genvej"
},
"instance.action.export-modpack": {
"message": "Eksporter modpack"
},
"instance.action.more-actions": {
"message": "Flere handlinger"
},
"instance.action.open-folder": {
"message": "Åbn mappe"
},
"instance.action.settings": {
"message": "Instans indstillinger"
},
"instance.action.starting": {
"message": "Starter..."
},
"instance.action.stopping": {
"message": "Stopper..."
},
"instance.add-server.add-and-play": {
"message": "Tilføj og spil"
},
@@ -611,6 +797,15 @@
"instance.files.save-as": {
"message": "Gem som..."
},
"instance.locked.delete-button": {
"message": "Slet instans"
},
"instance.locked.play-tooltip": {
"message": "Denne instans er blevet låst"
},
"instance.playtime.never-played": {
"message": "Aldrig spillet"
},
"instance.server-modal.address": {
"message": "Adresse"
},
@@ -782,6 +977,33 @@
"instance.settings.tabs.window.width.enter": {
"message": "Indtast bredde..."
},
"instance.shared-instance.error.title": {
"message": "Noget gik galt"
},
"instance.shared-instance.publish-review.added-label": {
"message": "Tilføjede"
},
"instance.shared-instance.publish-review.admonition-header": {
"message": "Skub opdatering til spillere"
},
"instance.shared-instance.publish-review.config-title-v2": {
"message": "Vælg konfigurations filer"
},
"instance.shared-instance.publish-review.header": {
"message": "Gennemså ændringer"
},
"instance.shared-instance.publish-review.publish-button": {
"message": "Skub opdatering"
},
"instance.shared-instance.publish-review.removed-label": {
"message": "Fjernede"
},
"instance.shared-instance.unavailable.locked-title": {
"message": "Instans låst"
},
"instance.shared-instance.unavailable.title": {
"message": "Delt instans er ikke længere tilgængelig"
},
"instance.worlds.a_minecraft_server": {
"message": "En Minecraft server"
},
@@ -791,6 +1013,9 @@
"instance.worlds.copy_address": {
"message": "Kopier adresse"
},
"instance.worlds.create_shortcut": {
"message": "Opret genvej"
},
"instance.worlds.dont_show_on_home": {
"message": "Vis ikke på forsiden"
},
@@ -842,6 +1067,54 @@
"minecraft-account.sign-in": {
"message": "Log ind på Minecraft"
},
"minecraft-required.description-header": {
"message": "Log ind med en Microsoft konto"
},
"minecraft-required.dont-have-account": {
"message": "Har du ikke en konto?"
},
"minecraft-required.get-minecraft": {
"message": "Få Minecraft"
},
"minecraft-required.get-support": {
"message": "Få hjælp"
},
"minecraft-required.header": {
"message": "Minecraft påkrævet"
},
"minecraft-required.sign-in": {
"message": "Log ind på Microsoft"
},
"modal.modrinth-account-required.cancel-button": {
"message": "Annuller"
},
"modal.modrinth-account-required.continue-in-browser-heading": {
"message": "Fortsæt i din browser"
},
"modal.modrinth-account-required.create-account-button": {
"message": "Opret en konto"
},
"modal.modrinth-account-required.header": {
"message": "Konto påkrævet"
},
"modal.modrinth-account-required.open-browser-again-button": {
"message": "Åbn browser igen"
},
"modal.modrinth-account-required.sign-in-button": {
"message": "Log ind på Modrinth"
},
"modal.modrinth-account-required.sign-in-heading": {
"message": "Log ind med en Modrinth konto"
},
"modal.modrinth-account-required.signing-in-header": {
"message": "Logger ind"
},
"modal.modrinth-account-required.support-prompt": {
"message": "Har du problemer med at logge ind? <support>Få hjælp</support>"
},
"modal.modrinth-account-required.waiting-for-browser": {
"message": "Venter på browser bekræftigelse..."
},
"search.filter.locked.instance": {
"message": "Givet af instanceen"
},

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