Compare commits

...
Author SHA1 Message Date
Gravy Boat 4bb2c4ef78 remove additional template 2026-08-26 18:29:29 -02:30
Gravy Boat e86f51f09a remove unused import 2026-08-26 15:22:04 -02:30
Gravy Boat 6a779d24a9 remove async await in report card setup 2026-08-26 07:56:33 -02:30
EpicPix 715f4954cf fix: handle minecraft version grouping for versions containing release name (#7313)
fix: handle minecraft version grouping for version containing release name better
2026-08-25 23:53:24 +00:00
Seungyeop Lee ffdf7ff745 Fixed settings modal category headers being cut off (#7258)
fix settings modal category headers being cut off
2026-08-25 23:20:21 +00:00
ThatGravyBoatandProspector a921cfdb7a feat: scan file for c2pa content (#7297)
* feat: scan file for c2pa content

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-25 22:41:39 +00:00
chyz 1007b7b69e Prevent SEO Slop (#7274)
* tell search engines not to index collections because bots keep spamming them and I can basically guarantee not a single human being is using collections in a way where this will be detrimental to them (besides the advertisement spam)

* Index creator created collections
2026-08-25 22:41:21 +00:00
Calum H.andProspector c28ec83060 feat: moderation additions for shared instances (#7285)
* feat: moderation additions for shared instances

* fix: lint

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-25 21:18:51 +00:00
Truman Gao 7701aef048 fix: creating a new group when only one group exists does not show the group (#7303)
fix: creating a new group when only one instance exists does not show the group
2026-08-25 19:47:37 +00:00
Sychic 7420560784 fix(app-lib): remove timeout for shared instance uploads (#7304)
* fix(app-lib): remove timeout for shared instance uploads

* style(app-lib): cargo fmt

* style(app-lib): remove unused import
2026-08-25 18:28:47 +00:00
aecsocket e33ef5f252 fix: cleanup typos with typos:off/on comments (#7301)
* fix: cleanup typos with typos:off/on comments

* tombi

* bump tombi version
2026-08-25 15:18:27 +00:00
Michael H. bb35950340 feat: usercheck & oauth email verification (#7291)
* feat: usercheck

* feat: make oauth users not skip email verification

* slightly improve message
2026-08-25 12:54:52 +00:00
Calum H.andProspector c67f980c4a refactor: StyledInput refactor -> Input components (#6733)
* feat: align style

* feat: new input components

* refactor: migrate to new input components

* refactor: rename component

* prepr

* chore: group inputs like buttons

* fix: lint

* feat: docs

* fix: lint

* fix: bugs

* prepr

* fix slug + adjust create server project

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-25 06:15:38 +00:00
Prospector 80607c1963 new dev favicons + settings favicon (#7295)
* new dev favicons + settings favicon

* prepr
2026-08-24 22:54:34 -07:00
Prospector ad83117ebf add keybinds to copy/open prod links (#7294) 2026-08-24 16:29:36 -07:00
Prospector 4b5edb9a99 update not found icons (#7289) 2026-08-24 16:29:24 -07:00
Prospector e753801e0c lazily load c2pa client-side to reduce CF size (#7292) 2026-08-24 16:17:48 -07:00
Truman Gao aed00aab13 fix: app sign in with button to open app (#7259)
* fix: app sign in with button to open app

* pnpm prepr
2026-08-24 16:57:27 +00:00
Calum H. 8d7e491126 fix: combobox behind advanced filters panel (#7281) 2026-08-24 16:45:14 +00:00
github-actions[bot]andgithub-merge-queue fb39dabe43 New translations from Crowdin (main) (#7280)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-08-24 09:22:50 +00:00
coolbot bc50ed5f65 moderation: new article placeholders and updated disclosures msgs (#7279)
placeholders for new articles, add disclosures FAQ to related messages.
2026-08-24 09:14:01 +00:00
ArthurandProspector fe9ae94a5d feat: add project_type filter to random projects endpoint (#6687)
* Fix random project being stale, add project_type filter

* Avoid full scan

* Add comment

* Run prepare, fix v2

* Trigger ci recheck

---------

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

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

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

* localization

---------

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

* fix: conditional auto padding being applied on user avatars

* feat: allow ungrouped to be dragged

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

* qa

* feat: add context menu submenus

* feat: hook up icon editing in sub menu

* fix: breadcrumb and sidebar not matching

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

* feat: add compact mode for library

* smaller checkmark

* pnpm prepr

* feat: bring back loader/game version sort

* add loader symbols

* refactor: compact mode with sync'd app settings

* pnpm prepr

* move loaders up

* qa

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

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

* changelog

* feat: implement depends on search filter

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

* feat: style pass

* feat: show included modpack projects filter tag better

* pnpm prepr

* feat: polish depends on filter

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

* feat: hook up excludes modpack

* fix: combobox closing race

* Revert "fix: combobox closing race"

This reverts commit e51529ca65.

* fix scrolling after applying depends on

* feedback

* pnpm prepr

* slightly make buttons larger for touch devices

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

* pnpm prepr

* fix: divider

* add icon

* fix: project search combobox background in app

---------

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

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

* fix comboboxes not aligning correctly immediately

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

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

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

* build: tweak gc, disable prerendering

* feat: pass along UA

* fix: fallback to process.env

* build: remove this pr branch test

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

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

* fix: DI issue

* fix: lint

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

* fix: sync btn when logged out

* fix: prepr

* fix: sidebar issue

* feat: language coverage + cleanup

* feat: cleanup lang settings

* fix: fmt

* fix: CI

* fix: ci

* fix: storybook

* feat: bring back loader/game version sort

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-08-20 07:23:01 +00:00
François-Xavier Talbot 79fcf41316 feat(frontend): lookup server by subdomain (#7210)
feat(frontend): lookup server by subdomain admin
2026-08-19 17:49:27 -04:00
Calum H. 10b1d7002a fix: crowdin ci (#7212)
fix: ci
2026-08-19 14:01:18 +02:00
Prospector 906ead762d update ads.txt 2026-08-18 08:52:31 -07:00
Prospector e568efa2a1 fix blog image 2026-08-17 22:31:49 -07:00
Prospector c8816418ea changelog 2026-08-17 20:40:22 -07:00
Prospector 12540a5fd7 update blog post 2026-08-17 20:40:13 -07:00
Space Walker fd7794c807 refactor: improve loader detection for Fabric derivatives (#7171)
improve loader detection for Fabric derivatives
2026-08-18 01:48:12 +00:00
Prospector b18081d962 changelog 2026-08-17 18:11:24 -07:00
Truman GaoandProspector e79e0d024b feat: app onboarding and library (#6923)
* refactor: remove unused legacy onboarded flag

* sqlx prepare

* feat: add onboarding checklist app db and add vue provider

* feat: add welcome page

* feat: add onboarding checklist component

* feat: move checklist visibility source of truth in app db

* feat: split up import modpack button

* feat-small: style tweak

* feat: remove discover section from homepage and add library

* refactor: move welcome screen to component

* refactor: move grid display into /library

* refactor: rename existing components

* refactor: split up the current library page files

* feat: add create group modal

* feat: add instance group definitions

* feat: update accordian for instance groups

* feat: add delete group

* feat: implement rename instance groups

* refactor: move instance group api out

* feat: add context menu for groups

* feat: style instance items in library

* feat: add selected instance item state

* feat: implement action bar for selection

* feat: drag and drop to move between groups

* feat: show ungrouped with header

* feat: improve edit group name

* small fix

* small style fixes

* feat: improve selection behaviour

* fix: dont toggle group when context menu open

* feat: drag and drop multiple selected

* small

* feat: add remove from group in action bar

* feat: add delay on icon hiding in inline input

* feat: improve icon hiding delay

* fix: bad routes.js

* feat: implement sort and filter options

* refactor: dont prefix filenames

* feat: change open/collapse group target

* feat: improve ungrouped behaviour

* feat: add shift to select multiple instances

* fix: icon disappear transition

* feat: add esc while dragging to cancel

* feat: update sorting and moved label

* feat: update context menu

* fix: multiple context menus can be opened

* refactor: instance groups to be identified by ID instead of name, names become display data

* feat: creating new group always adds instance

* feat: create group button, fix instance selection, and increase group name max length

* feat: strokes

* fix: show getting started checklist in all sidebar

* feat: set size on microsoft login screen

* fix: use ButtonStyled in Chips component

* feat: creating groups with selected instances and fix some moving selection and duplication bugs

* pnpm prepr

* feat: add tooltip for instance already in group

* feat: create instance with content in creation flow modal

- search modpack becomes search projects
- creation flow modal context receives prepareProjectInstall and createProjectInstall callbacks, from content-install.ts (shared with ContentInstallModal)

* feat: update instance card styles

* refactor: separate out instance card content

* update styles

* refactor: instance card

* fix: width of instance

* feat: update empty state

* feat: update sort and grouping dropdowns

* feat: better context menu options

* feat: add group action buttons

* feat: add/remove favourites group

* remove manual sort

* feat: improve the "New group" jank

* feat: add group by instance type and change group sorting

* feat: welcome screen styles match figma

* feat: not signed in skins demo

* feat: new hosting empty state

* feat: new instance card style

* feat: add tooltip and spice up animation more

* feat: improve the transitions for groups after dropping many instances

* feat: set instance group membership for bulk moving

* feat: remove fade out

* fix: allow dragging from buttons inside instance card

* fix: library page load in behaviour

* small fix

* feat: add group ordering and reordering

* feat: add drag to reorder groups

* fix: switching page not clearing selected instances

* style fixes

* style fix

* fix spacing

* feat: jump in changes

* feat: add styled newly created instances

* feat: improve jump in cards styles

* fix: welcome screen style

* fix: empty state styles

* feat: max 5 jump in items

* feat: max new instances to 3

* fix: search icon

* feat: clear selection after move

* feat: skins list use check icon styles

* feat: list will be deleted instances and default sort last played

* fix: stroke

* remove: no more group settings in instance general settings

* fix: resolving modpack server install

* feat: icon editor

* feat: add three hue shifted variants

* fix: surprise me doesnt pick symbol

* feat: add customize icon to create instance modal

* feat: skins loading state style

* fix: welcome logo

* fix: copy

* fix: left over usage of ButtonStyled from main's refactor

* feat: some leaked merge stuff

* feat: server empty state styles

* remove: instance helper text

* remove: auto link in set up stage

* feat: auto focus on search when modal open

* fix: delete instance modal style

* remove: modpack meta repeating

* fix: icon

* fix: font weight

* fix: dropdown filter preview heights

* pnpm prepr

* fix: sign into modrinth flashing change in position

* feat: add avatar transparent corner gets padding

* feat: drag and hold over group opens group

* feat: add random icon for custom instance

* feat: update loading icon

* polish: empty state for search

* polish: jump in cards

* fix: avatar could skip checking for image if tauri loads it first

* feat: add icon customize and randomizer in content install modal for add to new instances

* fix: avatar cannot read image pixels due to cors

* refactor: popup notifications toast type for instance sharing notifications

* feat: apply random icons modal and add popup notification for it

* polish: style

* fix: set_webview_visible

* QA

* qa

* feat: add inner white 15 opacity stroke around avatar

* add to catalog

* pnpm prepr

* fix: app event import

* feat: add new icons and backgrounds for icon editor

* feat: recents list generate from randomized icons and save icon in editor + icon editor modal fixes

* qa

* refactor: recipe -> config

* fix: event bus

* polish

* feat: add safe guard against randomizing empty icons

* feat: navigate to instance after creation

* fix: icon file names

* i18n on welcome screen

* i18n pass on PR

* fix: inconsistent max group name length

* refactor: use sqlx macro

* feat: add instance icon background selection drag

* feat: update catalog order

* fix: remove gradient for welcome screen

* remove: frog

* fix: min width

* feat: update corner size

* fix: context menu styles

* prepr

* fix: instance name trimming

* reorder

* fix: instance card loading state stayed until hovering

* feat: add icon randomizer blacklist

* blog

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-18 01:08:09 +00:00
Prospector d5556271f9 fix saving hooks + env vars (#7176) 2026-08-17 17:46:32 -07:00
Calum H. 1b341a8e07 fix: shared instance report ctx not working on indv page (#7182)
fix: shared instance report ctx not working
2026-08-18 00:45:00 +00:00
Calum H. bac2a55bb7 qa: content (#7184)
* qa: content card improvements for server panel

* fix: server install icon bug

* qa: 1

* fix: lint

* qa: 2
2026-08-18 00:39:51 +00:00
Calum H. 2f01c29b7d fix: unlinked instances not checking for updates correctly (#7186) 2026-08-18 00:39:44 +00:00
Calum H. 04b1d70823 fix: gallery viewer issue in app (#7187) 2026-08-18 00:39:32 +00:00
Calum H. 1c02e2fc50 fix: hover state on reload btn (#7188) 2026-08-18 00:39:07 +00:00
François-Xavier Talbot d0f85edcfa chore: add NSLocalNetworkUsageDescription to Info.plist (#7189) 2026-08-18 00:27:47 +00:00
Sychic 39c0951ecb feat(labrinth): check privacy route (#7195)
* feat(labrinth): check privacy endpoint

* refactor(labrinth): remove duplicated code
2026-08-17 22:09:50 +00:00
Sychic 46d07163cd feat(labrinth): user preferences (#7177)
* feat(labrinth): initial preferences structure

* feat(labrinth): use partially to derive partial structs

* feat(labrinth): preference defaults

* fix(labrinth): derive debug

* feat(labrinth): user preferences db setup

* feat(labrinth): user preferences routes

* fix(labrinth): nested partial structs

* fix(labrinth): serialize enums as snake case

* refactor(labrinth-derive): rename to component-derive

* feat(component-derive): nested components

* refactor(component-derive): change suffixes to prefixes

* refactor(component-derive): rename edit to partial

* feat(component-derive): skip serializing empty option

* refactor(labrinth): wrap errors

* feat(component-derive): diff function

* style(labrinth): fmt

* refactor(labrinth): use component derive and only store overrides

* chore: update query cache

* docs(labrinth): add preferences to openapi

* fix(labrinth): lock row for update

* remove: partially

* refactor(component-derive): split impls to separate functions

* feat(component-derive): wrap impls to isolate naming

* refactor(labrinth): split out auth conditions

* feat(labrinth): store auto

* feat(labrinth): store hosting privacy

* style(labrinth): cargo fmt
2026-08-17 17:57:57 +00:00
aecsocket b3b0b85691 fix: change incremental search indexing operation set (#7179)
* use ro_pool for search indexing again

* adjust incremental indexing operations
2026-08-17 17:00:12 +00:00
Prospector 248c7bb217 add AI report types + fix report page fetching (#7175)
* add AI report types + fix report page fetching

* use auth middleware on report
2026-08-16 23:55:14 -07:00
Prospector 65df8b90b8 better unknown project embed (#7178)
* better unknown project embed

* improve placeholders, fix decriptions
2026-08-16 23:54:17 -07:00
Michael H. 377ebfe746 fix: remove modrinth-bot from commentss 2026-08-16 23:03:01 +02:00
Prospector 14b2943dbe deploy moderation changes (#7169) 2026-08-15 17:24:14 -07:00
coolbot 5b16a774f9 Various moderation updates (#7160)
* Update status alerts, permission quick replies, utils, rules, disclosures.

* typo
2026-08-16 00:23:55 +00:00
816 changed files with 59285 additions and 21389 deletions
+3 -1
View File
@@ -14,12 +14,14 @@ jobs:
comment:
name: Post changelog comment
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Post or update changelog comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
github-token: ${{ github.token }}
script: |
const marker = '<!-- changelog -->';
const mergedMarker = '<!-- changelog-merged -->';
+2 -2
View File
@@ -20,8 +20,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
- uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
with:
tool: tombi
tool: tombi@1.4.1
- run: tombi lint
- run: tombi fmt --check
+2 -2
View File
@@ -10,6 +10,7 @@ on:
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-deploy.yml'
@@ -124,8 +125,7 @@ jobs:
run: pnpm install
- name: Build frontend
working-directory: ./apps/frontend
run: pnpm build
run: pnpm web:build
env:
CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
+99
View File
@@ -0,0 +1,99 @@
name: Frontend Docker
on:
push:
branches:
- main
- prod
paths:
- 'apps/frontend/**/*'
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- 'packages/api-client/**/*'
- 'packages/blog/**/*'
- '**/pnpm-*.yaml'
- .github/workflows/frontend-docker.yml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
jobs:
build:
name: Build frontend
runs-on: namespace-profile-modrinth-frontend
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Configure environment
id: meta
env:
REF: ${{ github.ref }}
run: |
if [ "$REF" = "refs/heads/main" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
else
echo "env=production" >> $GITHUB_OUTPUT
fi
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Enable Corepack
run: corepack enable
- name: Set up caches
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
pnpm
- name: Inject build variables
working-directory: ./apps/frontend
run: |
if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then
echo "Injecting staging variables from wrangler.jsonc..."
jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
else
echo "Injecting production variables from wrangler.jsonc..."
jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
fi
- name: Install dependencies
working-directory: ./apps/frontend
run: pnpm install
- name: Build frontend
run: pnpm web:build
env:
NITRO_PRESET: node-server
BUILD_ENV: ${{ steps.meta.outputs.env }}
CF_PAGES_BRANCH: ${{ github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
- name: Stage Docker context
run: |
mkdir -p apps/frontend/docker-stage
cp -r apps/frontend/.output apps/frontend/docker-stage/.output
- name: Upload Docker context
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
with:
name: frontend-docker-context
retention-days: 1
path: apps/frontend/docker-stage
docker-build:
needs: [build]
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
with:
image-name: frontend
dockerfile-path: apps/frontend/Dockerfile
artifacts-name: frontend-docker-context
+6 -4
View File
@@ -7,6 +7,7 @@ on:
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-preview.yml'
@@ -69,10 +70,9 @@ jobs:
run: pnpm install
- name: Build Storybook
working-directory: ./packages/ui
env:
NODE_OPTIONS: --max-old-space-size=6144
run: pnpm run build-storybook
run: pnpm build-storybook
- name: Configure short SHA
id: meta
@@ -117,6 +117,8 @@ jobs:
&& needs.deploy.result == 'success'
runs-on: ubuntu-latest
needs: [metadata, deploy, deploy-storybook]
permissions:
pull-requests: write
steps:
- name: Download deployment URLs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -157,7 +159,7 @@ jobs:
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: fc
with:
token: ${{ secrets.CROWDIN_GH_TOKEN }}
token: ${{ github.token }}
issue-number: ${{ github.event.pull_request.number }}
body-includes: Frontend previews
@@ -165,7 +167,7 @@ jobs:
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
token: ${{ secrets.CROWDIN_GH_TOKEN }}
token: ${{ github.token }}
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body: |
+4 -15
View File
@@ -8,6 +8,10 @@ on:
concurrency:
group: i18n-management
permissions:
contents: write
pull-requests: write
jobs:
pull_translations:
name: 'Pull translations from Crowdin'
@@ -38,23 +42,17 @@ jobs:
flight_failure "CROWDIN_PERSONAL_TOKEN secret is not defined (required to push)"
fi
if [ "$CROWDIN_GH_TOKEN_DEFINED" != true ]; then
flight_failure "CROWDIN_GH_TOKEN secret is not defined (required to make pull requests)"
fi
if [ "$PREFLIGHT_CHECK_RESULT" = false ]; then
exit 1
fi
env:
CROWDIN_PROJECT_ID_DEFINED: ${{ vars.CROWDIN_PROJECT_ID != '' }}
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.ref }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -67,12 +65,6 @@ jobs:
- name: Install script dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Configure Git author
id: git-author
uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
env:
GITHUB_TOKEN: ${{ secrets.CROWDIN_GH_TOKEN }}
# # Because --all flag of Crowdin CLI is currently broken we need to create a fake source file
# # so that the CLI won't omit translations for it. See https://github.com/crowdin/crowdin-cli/issues/724
# - name: Write fake sources
@@ -116,7 +108,4 @@ jobs:
body-path: .github/templates/crowdin-pr.md
commit-message: 'New translations from Crowdin (${{ steps.branch-name.outputs.branch_name }})'
branch: crowdin-pull/${{ steps.branch-name.outputs.branch_name }}
author: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
committer: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
labels: sync
token: ${{ secrets.CROWDIN_GH_TOKEN }}
+1
View File
@@ -55,6 +55,7 @@ Thumbs.db
# frontend generated files
apps/frontend/src/generated
/packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.ts
.turbo
target
+1
View File
@@ -28,6 +28,7 @@
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/xredis/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/component-derive/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
Generated
+12 -23
View File
@@ -2223,6 +2223,16 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "component-derive"
version = "0.0.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "compression-codecs"
version = "0.4.31"
@@ -2666,10 +2676,10 @@ dependencies = [
"futures",
"indexmap 2.11.4",
"itertools 0.14.0",
"quick-xml 0.38.3",
"reqwest 0.12.24",
"rust-s3",
"serde",
"serde-xml-rs",
"serde_json",
"sha1_smol",
"thiserror 2.0.17",
@@ -5480,6 +5490,7 @@ dependencies = [
"clickhouse",
"color-eyre",
"color-thief",
"component-derive",
"const_format",
"dashmap",
"derive_more 2.1.1",
@@ -5557,16 +5568,6 @@ dependencies = [
"zxcvbn",
]
[[package]]
name = "labrinth-derive"
version = "0.0.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "language-tags"
version = "0.3.2"
@@ -9432,18 +9433,6 @@ dependencies = [
"typeid",
]
[[package]]
name = "serde-xml-rs"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53630160a98edebde0123eb4dfd0fce6adff091b2305db3154a9e920206eb510"
dependencies = [
"log",
"serde",
"thiserror 1.0.69",
"xml-rs",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
+5 -12
View File
@@ -7,8 +7,8 @@ members = [
"apps/labrinth",
"packages/app-lib",
"packages/ariadne",
"packages/component-derive",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-content-management",
"packages/modrinth-log",
"packages/modrinth-maxmind",
@@ -42,9 +42,7 @@ async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.89"
async-tungstenite = { version = "0.31.0", default-features = false, features = [
"futures-03-sink"
] }
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
async-walkdir = "2.1.0"
async_zip = "0.0.18"
aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
@@ -61,13 +59,12 @@ censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.42"
chumsky = "0.9.3"
cidre = { version = "0.15.0", default-features = false, features = [
"macos_15_0"
] }
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
clap = "4.5.48"
clickhouse = "0.14.0"
color-eyre = "0.6.5"
color-thief = "0.2.2"
component-derive = { path = "packages/component-derive" }
const_format = "0.2.34"
core-foundation = "0.10.1"
core-graphics = "0.24.0"
@@ -162,10 +159,7 @@ redis = "1.4.1"
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
rust_decimal = { version = "1.39.0", features = [
"serde-with-float",
"serde-with-str"
] }
rust_decimal = { version = "1.39.0", features = ["serde-with-float", "serde-with-str"] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.37.0", default-features = false, features = [
"fail-on-err",
@@ -191,7 +185,6 @@ serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.145"
serde_with = "3.15.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
+10 -7
View File
@@ -6,13 +6,16 @@ extend-exclude = [
"packages/utils/",
"packages/ui/",
"packages/blog/",
# contains licenses like `CC-BY-ND-4.0`
"packages/moderation/src/data/stages/license.ts",
"packages/moderation/src/utils.ts",
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
"apps/labrinth/src/queue/payouts/mod.rs",
# contains domain names with deliberate typos
"apps/labrinth/assets/disposable_email_blocklist.txt"
]
[default]
extend-ignore-re = [
# Ignore lines that end with `// typos:ignore`
"(?Rm)^.*(#|//)\\s*typos:ignore$",
# Ignore the line after `// typos:ignore`:
"(#|//)\\s*typos:ignore\\n.*",
# Ignore blocks between `// typos:off` and `// typos:on`
"(?s)(#|//)\\s*typos:off.*?\\n\\s*(#|//)\\s*typos:on",
]
[default.extend-words]
+1 -1
View File
@@ -14,6 +14,7 @@
"test": "vue-tsc --noEmit"
},
"dependencies": {
"@dnd-kit/vue": "^0.5.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
@@ -37,7 +38,6 @@
"intl-messageformat": "^10.7.7",
"ofetch": "^1.3.4",
"overlayscrollbars": "^2.15.1",
"pinia": "^3.0.0",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
+265 -78
View File
@@ -13,11 +13,10 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
HomeIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
@@ -57,7 +56,7 @@ import {
import { renderString } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -74,6 +73,7 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
import HostingUpdateRequired from '@/components/ui/HostingUpdateRequired.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
@@ -82,6 +82,9 @@ import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyIn
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import NewIconEditorNotification from '@/components/ui/new-icon-editor-notification/index.vue'
import { shouldShowNewIconEditorNotification } from '@/components/ui/new-icon-editor-notification/show-notification'
import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
@@ -91,6 +94,9 @@ import SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { useError } from '@/composables/use-error.js'
import { useTheme } from '@/composables/use-theme.ts'
import { config } from '@/config'
import {
hide_ads_window,
@@ -144,8 +150,7 @@ import { setupProviders } from '@/providers/setup'
import { setupAppEventsProvider } from '@/providers/setup/app-events'
import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { useError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import { setupAppUserPreferencesProvider } from '@/providers/setup/user-preferences.ts'
import { appMessages } from '@/utils/app-messages'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
@@ -154,7 +159,8 @@ import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming()
const appSettings = useAppSettings()
const appTheme = useTheme()
const router = useRouter()
const route = useRoute()
const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider()
@@ -198,7 +204,7 @@ const credentials = ref()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
watch(
() => themeStore.toggleSidebar,
() => appSettings.toggleSidebar,
(toggleSidebar) => {
sidebarToggled.value = !toggleSidebar
},
@@ -218,7 +224,7 @@ const hostingUpdateRequired = computed(
appUpdateState.updatesEnabled.value,
)
const prideFundraiserEnabled = computed(
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
() => appSettings.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
)
const hostingIntercomIdentityKey = computed(() => {
const rawServerId = route.params.id
@@ -248,7 +254,7 @@ useAppEvent(
'warning',
(event) =>
addNotification({
title: 'Warning',
title: formatMessage(messages.warning),
text: event.message,
type: 'warning',
}),
@@ -321,30 +327,79 @@ providePageContext({
intercomBubble: hostingIntercom.intercomBubble,
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
appSettings.getFeatureFlag('server_ram_as_bytes_always_on'),
),
},
openExternalUrl: (url) => void openUrl(url),
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
noblur: computed(() => !appTheme.advancedRendering),
onShow: () => take_ads_window_hold(),
onHide: () => release_ads_window_hold(),
})
const creationIconEditorModal = ref(null)
const creationGeneratedIcon = ref(null)
const creationIconTarget = ref('creation-flow')
const {
installationModal,
unknownPackWarningModal,
fetchExistingInstanceNames,
handleCreate,
handleBrowseModpacks,
searchModpacks,
getProjectVersions,
searchProjects,
getLoaderManifest,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance,
} = setupProviders(tauriApiClient, notificationManager, popupNotificationManager)
onboardingChecklist,
} = setupProviders(
tauriApiClient,
notificationManager,
popupNotificationManager,
appEvents,
(iconPath) =>
creationGeneratedIcon.value?.path === iconPath ? creationGeneratedIcon.value.config : null,
)
const { hasLoggedIntoMinecraft, hasLoggedIntoModrinth, showChecklist } = onboardingChecklist
const showFriendsList = computed(() => !showChecklist.value || hasLoggedIntoModrinth.value)
async function randomizeCreationIcon() {
const generated = await creationIconEditorModal.value?.randomizeAndSave()
if (!generated) return null
creationGeneratedIcon.value = { path: generated.iconPath, config: generated.config }
return {
path: generated.iconPath,
previewUrl: convertFileSrc(generated.iconPath),
}
}
function customizeCreationIcon() {
creationIconTarget.value = 'creation-flow'
creationIconEditorModal.value?.show()
}
function customizeContentInstallIcon() {
creationIconTarget.value = 'content-install'
creationIconEditorModal.value?.show()
}
function onCreationIconSaved(iconPath, config) {
creationGeneratedIcon.value = { path: iconPath, config }
if (creationIconTarget.value === 'content-install') {
modInstallModal.value?.setIcon(iconPath, convertFileSrc(iconPath))
return
}
const context = installationModal.value?.ctx
if (!context) return
context.instanceIcon.value = null
context.instanceIconUrl.value = convertFileSrc(iconPath)
context.instanceIconPath.value = iconPath
}
const news = ref([])
const displayedServerInviteNotifications = new Set()
@@ -360,7 +415,6 @@ window.addEventListener('online', () => {
offline.value = false
})
const showOnboarding = ref(false)
const nativeDecorations = ref(false)
const os = ref('')
@@ -425,6 +479,15 @@ const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const messages = defineMessages({
warning: { id: 'app.notification.warning', defaultMessage: 'Warning' },
moreOptions: { id: 'app.navigation.more-options', defaultMessage: 'More options' },
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
updateDownloadMissingVersion: {
id: 'app.update.download-error.missing-version',
defaultMessage: 'Failed to download update: no version available',
},
updateInstalledToastTitle: {
id: 'app.update.complete-toast.title',
defaultMessage: 'Version {version} was successfully installed!',
@@ -467,10 +530,6 @@ const messages = defineMessages({
id: 'app.nav.home',
defaultMessage: 'Home',
},
library: {
id: 'app.nav.library',
defaultMessage: 'Library',
},
modrinthHosting: {
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
@@ -530,6 +589,7 @@ function handleAdsConsentRequired(required) {
}
const notification = addPopupNotification({
contentType: 'standard',
title: formatMessage(messages.adsConsentTitle),
text: formatMessage(messages.adsConsentBody),
type: 'info',
@@ -562,17 +622,26 @@ function handleAdsConsentRequired(required) {
}
async function setupApp() {
await onboardingChecklist.initialize()
if (shouldShowNewIconEditorNotification(showChecklist.value)) {
addPopupNotification({
contentType: 'custom',
component: NewIconEditorNotification,
autoCloseMs: null,
})
}
const {
native_decorations,
theme,
locale,
telemetry,
collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
onboarded,
default_page,
toggle_sidebar,
sync_theme_across_devices,
sync_behavior_across_devices,
developer_mode,
feature_flags,
pending_update_toast_for_version,
@@ -583,26 +652,21 @@ async function setupApp() {
i18n.global.locale.value = locale
}
if (default_page === 'Library') {
await router.push('/library')
}
os.value = await getOS()
const dev = await isDev()
isDevEnvironment.value = dev
const version = await getVersion()
showOnboarding.value = !onboarded
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
themeStore.setThemeState(theme)
themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering
themeStore.hideNametagSkinsPage = hide_nametag_skins_page
themeStore.toggleSidebar = toggle_sidebar
themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags
appTheme.preferred = theme
appTheme.advancedRendering = advanced_rendering
appTheme.syncAcrossDevices = sync_theme_across_devices
appSettings.syncBehaviorAcrossDevices = sync_behavior_across_devices
appSettings.hideNametagSkinsPage = hide_nametag_skins_page
appSettings.toggleSidebar = toggle_sidebar
appSettings.devMode = developer_mode
Object.assign(appSettings.featureFlags, feature_flags)
stateInitialized.value = true
isMaximized.value = await getCurrentWindow().isMaximized()
@@ -614,7 +678,7 @@ async function setupApp() {
if (telemetry) {
initAnalytics()
if (dev) debugAnalytics()
trackEvent('Launched', { version, dev, onboarded })
trackEvent('Launched', { version, dev })
}
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
@@ -818,6 +882,7 @@ const {
projectInfo: contentInstallProjectInfo,
handleInstallToInstance,
handleCreateAndInstall,
prepareNewInstance,
handleNavigate: handleContentInstallNavigate,
handleCancel: handleContentInstallCancel,
setContentInstallModal,
@@ -837,6 +902,35 @@ const {
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
} = contentInstall
async function prepareCreationProjectInstall(projectId, projectType) {
if (projectType === 'modpack') {
await contentInstall.install(
projectId,
null,
null,
'CreationModalProject',
undefined,
(instanceId) => void router.push(`/instance/${encodeURIComponent(instanceId)}`),
)
return null
}
await prepareNewInstance(projectId)
const info = contentInstallProjectInfo.value
if (!info) throw new Error(`Project information is unavailable: '${projectId}'`)
return {
projectId,
title: info.title,
iconUrl: info.iconUrl,
link: info.link,
owner: info.owner,
compatibleLoaders: [...contentInstallLoaders.value],
gameVersions: [...contentInstallGameVersions.value],
releaseGameVersions: new Set(contentInstallReleaseGameVersions.value),
}
}
const serverInstall = createServerInstall({
router,
handleError,
@@ -870,7 +964,7 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
const authProvider = setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
if (options?.showModal === false) {
await signIn(flow)
} else {
@@ -878,6 +972,80 @@ setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
}
})
const userPreferences = setupAppUserPreferencesProvider(authProvider, notificationManager)
let userPreferencesSync = Promise.resolve()
watch(
[userPreferences.preferences, stateInitialized],
([preferences, initialized]) => {
if (!preferences || !initialized) return
userPreferencesSync = userPreferencesSync
.then(async () => {
const settings = await getSettings()
const selectedTheme = preferences.appearance.auto ? 'system' : preferences.appearance.theme
const locale = preferences.localization.locale
const behavior = preferences.behavior
let settingsChanged = false
if (appTheme.syncAcrossDevices && appTheme.preferred !== selectedTheme) {
appTheme.preferred = selectedTheme
}
if (i18n.global.locale.value !== locale) {
i18n.global.locale.value = locale
}
if (appTheme.syncAcrossDevices && settings.theme !== selectedTheme) {
settings.theme = selectedTheme
settingsChanged = true
}
if (settings.locale !== locale) {
settings.locale = locale
settingsChanged = true
}
if (behavior && appSettings.syncBehaviorAcrossDevices) {
const behaviorFeatureFlags = {
worlds_in_home: behavior.show_jump_in,
compact_instance_cards: behavior.compact_instance_cards,
show_instance_play_time: behavior.show_play_time,
skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks,
skip_non_essential_warnings: behavior.skip_non_essential_warnings,
}
appSettings.toggleSidebar = behavior.hide_right_sidebar
appSettings.hideNametagSkinsPage = behavior.hide_nametag
Object.assign(appSettings.featureFlags, behaviorFeatureFlags)
if (settings.hide_on_process_start !== behavior.minimize_app) {
settings.hide_on_process_start = behavior.minimize_app
settingsChanged = true
}
if (settings.toggle_sidebar !== behavior.hide_right_sidebar) {
settings.toggle_sidebar = behavior.hide_right_sidebar
settingsChanged = true
}
if (settings.hide_nametag_skins_page !== behavior.hide_nametag) {
settings.hide_nametag_skins_page = behavior.hide_nametag
settingsChanged = true
}
for (const [flag, value] of Object.entries(behaviorFeatureFlags)) {
if (settings.feature_flags[flag] !== value) {
settings.feature_flags[flag] = value
settingsChanged = true
}
}
}
if (settingsChanged) {
await setSettings(settings)
}
})
.catch(handleError)
},
{ immediate: true },
)
async function validateSession(sessionToken) {
try {
const response = await tauriFetch(`${config.labrinthBaseUrl}/v2/user`, {
@@ -1090,17 +1258,16 @@ async function handleLiveNotification(notification) {
if (generation !== liveNotificationGeneration) return
const popupNotification = addPopupNotification({
contentType: 'toast',
title: serverName,
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
})
serverInvitePopupNotificationIds.add(popupNotification.id)
}
@@ -1277,6 +1444,7 @@ function showDelayedUpdatePopup() {
if (metered.value && !finishedDownloading.value) {
addPopupNotification({
contentType: 'standard',
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
type: 'info',
@@ -1298,6 +1466,7 @@ function showDelayedUpdatePopup() {
})
} else if (finishedDownloading.value) {
addPopupNotification({
contentType: 'standard',
title: formatMessage(updatePopupMessages.downloadComplete),
text: formatMessage(updatePopupMessages.downloadedBody, {
version: update.version,
@@ -1396,6 +1565,7 @@ async function checkLinuxUpdates() {
const nextPopupTime = getNextAppUpdatePopupTime(latestVersion)
if (nextPopupTime !== null && Date.now() >= nextPopupTime) {
addPopupNotification({
contentType: 'standard',
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
type: 'info',
@@ -1415,7 +1585,7 @@ async function downloadAvailableUpdate() {
async function downloadUpdate(versionToDownload) {
if (!versionToDownload) {
handleError(`Failed to download update: no version available`)
handleError(formatMessage(messages.updateDownloadMissingVersion))
return
}
@@ -1432,9 +1602,8 @@ async function downloadUpdate(versionToDownload) {
.then(() => {
downloading.value = false
finishedDownloading.value = true
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
unlistenUpdateDownload?.()
unlistenUpdateDownload = null
console.log('Finished downloading!')
markAppUpdateActionable(versionToDownload.version, 'downloaded')
scheduleDelayedUpdatePopup()
@@ -1551,7 +1720,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div
v-if="stateInitialized"
class="app-grid-layout relative"
:class="{ 'disable-advanced-rendering': !themeStore.advancedRendering }"
:class="{ 'disable-advanced-rendering': !appTheme.advancedRendering }"
>
<Transition name="fade">
<div
@@ -1579,18 +1748,34 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
type="instance"
show-snapshot-toggle
:fetch-existing-instance-names="fetchExistingInstanceNames"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
:search-projects="searchProjects"
:prepare-project-install="prepareCreationProjectInstall"
:create-project-install="handleCreateAndInstall"
:get-loader-manifest="getLoaderManifest"
:randomize-instance-icon="randomizeCreationIcon"
:customize-instance-icon="customizeCreationIcon"
@create="handleCreate"
@browse-modpacks="handleBrowseModpacks"
/>
<IconEditorModal
ref="creationIconEditorModal"
:config="creationGeneratedIcon?.config"
@saved="onCreationIconSaved"
/>
<UnknownPackWarningModal ref="unknownPackWarningModal" />
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.25rem] w-[--left-bar-width]"
>
<NavButton v-tooltip.right="formatMessage(messages.home)" to="/">
<HomeIcon />
<NavButton
v-tooltip.right="formatMessage(messages.home)"
to="/"
:is-primary="(route) => route.path === '/'"
:is-subpage="
() =>
(route.path.startsWith('/browse') || route.path.startsWith('/project')) && route.query.i
"
>
<PlayIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
@@ -1605,19 +1790,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
<ShirtIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.library)"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
() =>
route.path.startsWith('/instance') ||
((route.path.startsWith('/browse') || route.path.startsWith('/project')) &&
route.query.i)
"
>
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
@@ -1652,7 +1824,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
v-tooltip.right="formatMessage(messages.modrinthAccount)"
type="quiet"
size="xl"
label="More options"
:label="formatMessage(messages.moreOptions)"
:options="[
{
id: 'view-profile',
@@ -1707,7 +1879,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<IconButton
type="outlined"
label="Go back"
:label="formatMessage(messages.goBack)"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateBack"
@click="router.back()"
@@ -1719,7 +1891,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</IconButton>
<IconButton
type="outlined"
label="Go forward"
:label="formatMessage(messages.goForward)"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateForward"
@click="router.forward()"
@@ -1734,9 +1906,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<IconButton
v-if="!forceSidebar && themeStore.toggleSidebar"
v-if="!forceSidebar && appSettings.toggleSidebar"
:type="sidebarToggled ? 'base' : 'quiet'"
label="Next image"
:label="formatMessage(messages.nextImage)"
class="mr-3 transition-transform"
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
@@ -1757,7 +1929,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="app-contents"
:class="{
'sidebar-enabled': sidebarVisible,
'disable-advanced-rendering': !themeStore.advancedRendering,
'disable-advanced-rendering': !appTheme.advancedRendering,
}"
>
<div class="app-viewport flex-grow router-view">
@@ -1773,7 +1945,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<LoadingBar position="absolute" />
</div>
<div
v-if="themeStore.featureFlags.page_path"
v-if="appSettings.featureFlags.page_path"
class="absolute bottom-0 left-0 m-2 bg-tooltip-bg text-tooltip-text font-semibold rounded-full px-2 py-1 text-xs z-50"
>
{{ route.fullPath }}
@@ -1808,7 +1980,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<RouterView v-else v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<component :is="Component"></component>
<KeepAlive include="LibraryPage">
<component :is="Component"></component>
</KeepAlive>
</Suspense>
</template>
</RouterView>
@@ -1823,9 +1997,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:class="{ 'pb-12': !hasPlus }"
data-overlayscrollbars-initialize
>
<OnboardingChecklist
@create-instance="installationModal?.show()"
@login-minecraft="accounts?.login()"
@login-modrinth="signIn"
/>
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<div
v-show="hasLoggedIntoMinecraft"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
</h3>
@@ -1833,7 +2015,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<AccountsCard ref="accounts" />
</suspense>
</div>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<div
v-show="showFriendsList"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<suspense>
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
</suspense>
@@ -1897,6 +2082,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:preferred-game-version="contentInstallPreferredGameVersion"
:release-game-versions="contentInstallReleaseGameVersions"
:project-info="contentInstallProjectInfo"
:randomize-icon="randomizeCreationIcon"
:customize-icon="customizeContentInstallIcon"
@install="handleInstallToInstance"
@create-and-install="handleCreateAndInstall"
@navigate="handleContentInstallNavigate"
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="30.8333" height="20.8333" viewBox="0 0 30.8333 20.8333" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M1.6228 20.8333C2.51907 20.8333 3.24562 20.1158 3.24562 19.2308L3.24563 17.5253C3.24562 17.0803 3.2459 16.016 3.7136 15.2292C4.00182 14.7442 4.37085 14.339 4.74993 14.1326C5.36078 13.8001 6.0611 13.6111 6.80555 13.6111C7.05077 13.6111 7.3144 13.6219 7.6104 13.6356L7.7201 13.6408C7.97828 13.6529 8.25927 13.6661 8.5395 13.6719C9.17298 13.6851 9.91222 13.6651 10.6258 13.4739C12.0158 13.1014 13.1015 12.0158 13.4738 10.6258C13.665 9.91222 13.685 9.17298 13.672 8.5395C13.6662 8.25937 13.6528 7.9782 13.6408 7.7201L13.6357 7.6104C13.6218 7.3144 13.6112 7.05077 13.6112 6.80555C13.6112 6.02607 13.8182 5.29497 14.1805 4.66428C14.3917 4.29663 14.7595 3.96015 15.2675 3.66463C16.0377 3.21645 16.96 3.2123 17.5237 3.20975C18.0872 3.20722 18.6818 3.20527 19.1787 3.20527C22.2357 3.20527 23.347 3.2282 24.1857 3.5193C25.6395 4.02402 26.7495 5.07765 27.2667 6.36838C27.3963 6.69185 27.4878 7.1167 27.5368 7.85255C27.5868 8.60265 27.5877 9.56012 27.5877 10.9399V19.2308C27.5877 20.1158 28.3143 20.8333 29.2105 20.8333C30.1068 20.8333 30.8333 20.1158 30.8333 19.2308V10.8854C30.8333 9.5729 30.8333 8.50977 30.7755 7.6419C30.7158 6.74778 30.5898 5.95072 30.2847 5.18902C29.4037 2.99072 27.5568 1.29222 25.262 0.495583C23.8303 -0.0014166 22.1092 -0.000816752 19.5178 8.32478e-05L17.939 0.000200073L17.9167 0L17.8582 0.000249942C13.6775 0.00308328 11.0862 0.0528001 8.93633 0.79915C5.23583 2.08378 2.2801 4.81432 0.87485 8.32102C0.40655 9.48963 0.199033 10.7449 0.0986334 12.2503C-1.66334e-05 13.7293 0 15.5563 0 17.8857V19.2308C0 20.1158 0.72655 20.8333 1.6228 20.8333Z" fill="#B0BAC5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

@@ -1,360 +0,0 @@
<script setup>
import {
ClipboardCopyIcon,
EyeIcon,
FolderOpenIcon,
PlayIcon,
PlusIcon,
SearchIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import {
Accordion,
DropdownSelect,
formatLoader,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { install_duplicate_instance } from '@/helpers/install'
import { remove } from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps({
instances: {
type: Array,
default() {
return []
},
},
label: {
type: String,
default: '',
},
})
const instanceOptions = ref(null)
const instanceComponents = ref(null)
const currentDeleteInstance = ref(null)
const confirmModal = ref(null)
async function deleteInstance() {
if (currentDeleteInstance.value) {
instanceComponents.value = instanceComponents.value.filter(
(x) => x.instance.id !== currentDeleteInstance.value,
)
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateInstance(p) {
await install_duplicate_instance(p).catch(handleError)
}
const handleRightClick = (event, instanceId) => {
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
const baseOptions = [
...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
{ name: 'copy' },
{ type: 'divider' },
{
name: 'delete',
color: 'danger',
},
]
instanceOptions.value.showMenu(
event,
item,
item.playing
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
...(item.instance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)
}
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
args.item.play(null, 'InstanceGridContextMenu')
break
case 'stop':
args.item.stop(null, 'InstanceGridContextMenu')
break
case 'add_content':
await args.item.addContent()
break
case 'edit':
await args.item.seeInstance()
break
case 'duplicate':
if (args.item.instance.install_stage == 'installed')
await duplicateInstance(args.item.instance.id)
break
case 'open':
await args.item.openFolder()
break
case 'copy':
await navigator.clipboard.writeText(args.item.instance.id)
break
case 'delete':
currentDeleteInstance.value = args.item.instance.id
confirmModal.value.show()
break
}
}
const state = useStorage(
`${props.label}-grid-display-state`,
{
group: 'Group',
sortBy: 'Name',
collapsedGroups: [],
},
localStorage,
{ mergeDefaults: true },
)
const search = ref('')
const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? []))
const getSectionKey = (sectionName) => `${state.value.group}:${sectionName}`
const isSectionCollapsed = (sectionName) => {
return collapsedSectionKeys.value.has(getSectionKey(sectionName))
}
const setSectionCollapsed = (sectionName, collapsed) => {
const sectionKey = getSectionKey(sectionName)
const collapsedSections = new Set(state.value.collapsedGroups ?? [])
if (collapsed) {
collapsedSections.add(sectionKey)
} else {
collapsedSections.delete(sectionKey)
}
state.value.collapsedGroups = [...collapsedSections]
}
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
const instances = props.instances.filter((instance) => {
return instance.name.toLowerCase().includes(search.value.toLowerCase())
})
if (sortBy === 'Name') {
instances.sort((a, b) => {
return a.name.localeCompare(b.name)
})
}
if (sortBy === 'Game version') {
instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
})
}
if (sortBy === 'Last played') {
instances.sort((a, b) => {
return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))
})
}
if (sortBy === 'Date created') {
instances.sort((a, b) => {
return dayjs(b.date_created).diff(dayjs(a.date_created))
})
}
if (sortBy === 'Date modified') {
instances.sort((a, b) => {
return dayjs(b.date_modified).diff(dayjs(a.date_modified))
})
}
const instanceMap = new Map()
if (group === 'Loader') {
instances.forEach((instance) => {
const loader = formatLoader(formatMessage, instance.loader)
if (!instanceMap.has(loader)) {
instanceMap.set(loader, [])
}
instanceMap.get(loader).push(instance)
})
} else if (group === 'Game version') {
instances.forEach((instance) => {
if (!instanceMap.has(instance.game_version)) {
instanceMap.set(instance.game_version, [])
}
instanceMap.get(instance.game_version).push(instance)
})
} else if (group === 'Group') {
instances.forEach((instance) => {
if (instance.groups.length === 0) {
instance.groups.push('None')
}
for (const category of instance.groups) {
if (!instanceMap.has(category)) {
instanceMap.set(category, [])
}
instanceMap.get(category).push(instance)
}
})
} else {
return instanceMap.set('None', instances)
}
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
if (sortBy === 'Name') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
// None should always be first
if (a[0] === 'None' && b[0] !== 'None') {
return -1
}
if (a[0] !== 'None' && b[0] === 'None') {
return 1
}
return a[0].localeCompare(b[0])
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap
})
</script>
<template>
<div class="flex gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
placeholder="Search"
clearable
wrapper-class="flex-1"
/>
<DropdownSelect
v-slot="{ selected }"
v-model="state.sortBy"
name="Sort Dropdown"
class="max-w-[16rem]"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="state.group"
class="max-w-[16rem]"
name="Group Dropdown"
:options="['Group', 'Loader', 'Game version', 'None']"
placeholder="Select..."
>
<span class="font-semibold text-primary">Group by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
</div>
<Accordion
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key,
value,
}))"
:key="instanceSection.key"
:divider="instanceSection.key !== 'None'"
:open-by-default="!isSectionCollapsed(instanceSection.key)"
class="row"
@on-open="setSectionCollapsed(instanceSection.key, false)"
@on-close="setSectionCollapsed(instanceSection.key, true)"
>
<template v-if="instanceSection.key !== 'None'" #title>
<span class="text-base">{{ instanceSection.key }}</span>
</template>
<section class="instances">
<Instance
v-for="instance in instanceSection.value"
ref="instanceComponents"
:key="instance.id + instance.install_stage"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.id)"
/>
</section>
</Accordion>
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteInstance" />
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #delete> <TrashIcon /> Delete </template>
<template #open> <FolderOpenIcon /> Open folder </template>
<template #copy> <ClipboardCopyIcon /> Copy path </template>
</ContextMenu>
</template>
<style lang="scss" scoped>
.row {
width: 100%;
}
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
width: 100%;
gap: 0.75rem;
margin-right: auto;
scroll-behavior: smooth;
overflow-y: auto;
}
</style>
@@ -1,371 +0,0 @@
<script setup>
import {
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { kill, remove, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process.js'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const router = useRouter()
const props = defineProps({
instances: {
type: Array,
default() {
return []
},
},
label: {
type: String,
default: '',
},
canPaginate: Boolean,
})
const actualInstances = computed(() =>
props.instances.filter(
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
),
)
const modsRow = ref(null)
const instanceOptions = ref(null)
const instanceComponents = ref(null)
const rows = ref(null)
const deleteConfirmModal = ref(null)
const currentDeleteInstance = ref(null)
async function deleteInstance() {
if (currentDeleteInstance.value) {
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateInstance(p) {
await install_duplicate_instance(p).catch(handleError)
}
const handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [
...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open_folder' },
{ name: 'copy_path' },
{ type: 'divider' },
{
name: 'delete',
color: 'danger',
},
]
const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError)
const options =
runningProcesses.length > 0
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
...(passedInstance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
]
instanceOptions.value.showMenu(event, passedInstance, options)
}
const handleProjectClick = (event, passedInstance) => {
instanceOptions.value.showMenu(event, passedInstance, [
{
name: 'install',
color: 'primary',
},
{ type: 'divider' },
{
name: 'open_link',
},
{
name: 'copy_link',
},
])
}
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
await run(args.item.id).catch((err) => handleSevereError(err, { instanceId: args.item.id }))
trackEvent('InstanceStart', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break
case 'stop':
await kill(args.item.id).catch(handleError)
trackEvent('InstanceStop', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break
case 'add_content':
await router.push({
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: args.item.id },
})
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(args.item.id)}`,
})
break
case 'duplicate':
if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id)
break
case 'delete':
currentDeleteInstance.value = args.item.id
deleteConfirmModal.value.show()
break
case 'open_folder':
await showInstanceInFolder(args.item.id)
break
case 'copy_path':
await navigator.clipboard.writeText(args.item.id)
break
case 'install': {
await installVersion(
args.item.project_id,
null,
null,
'ProjectCardContextMenu',
() => {},
() => {},
).catch(handleError)
break
}
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
break
case 'copy_link':
await navigator.clipboard.writeText(
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
)
break
}
}
const maxInstancesPerCompactRow = ref(1)
const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
if (rows.value.length === 0) {
return
}
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2
}
if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2
}
if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2
}
}
const rowContainer = ref(null)
const resizeObserver = ref(null)
onMounted(() => {
calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value)
}
window.addEventListener('resize', calculateCardsPerRow)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value)
}
})
</script>
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteInstance" />
<div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route">
{{ row.label }}
</HeadingLink>
<section
v-if="row.instance"
ref="modsRow"
class="instances"
:class="{ compact: row.compact }"
>
<Instance
v-for="(instance, instanceIndex) in row.instances.slice(
0,
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
)"
:key="row.label + instance.id"
:instance="instance"
:compact="row.compact"
:first="instanceIndex === 0"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/>
</section>
<section v-else ref="modsRow" class="projects">
<LegacyProjectCard
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
:key="project?.project_id"
ref="instanceComponents"
class="item"
:project="project"
@contextmenu.prevent.stop="(event) => handleProjectClick(event, project)"
/>
</section>
</div>
</div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template>
<template #delete> <TrashIcon /> Delete </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
</ContextMenu>
</template>
<style lang="scss" scoped>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
gap: 1rem;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
background: transparent;
}
}
.row {
display: flex;
flex-direction: column;
align-items: flex-start;
overflow: hidden;
width: 100%;
min-width: 100%;
&:nth-child(even) {
background: var(--color-bg);
}
.header {
width: 100%;
margin-bottom: 1rem;
gap: var(--gap-xs);
display: flex;
flex-direction: row;
align-items: center;
a {
margin: 0;
font-size: var(--font-size-md);
font-weight: bolder;
white-space: nowrap;
color: var(--color-base);
}
svg {
height: 1.25rem;
width: 1.25rem;
color: var(--color-base);
}
}
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-gap: 0.75rem;
width: 100%;
&.compact {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 0.75rem;
}
}
.projects {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 0.75rem;
.item {
width: 100%;
max-width: 100%;
}
}
}
</style>
@@ -20,6 +20,7 @@
<div class="flex gap-2 w-full min-w-0">
<Avatar
size="36px"
disable-conditional-icon-padding
:src="
selectedAccount
? avatarUrl
@@ -46,7 +47,11 @@
class="w-5 h-5 text-brand shrink-0"
/>
<RadioButtonIcon v-else class="w-5 h-5 text-secondary shrink-0" />
<Avatar :src="getAccountAvatarUrl(account)" size="24px" />
<Avatar
:src="getAccountAvatarUrl(account)"
size="24px"
disable-conditional-icon-padding
/>
<p
class="m-0 truncate min-w-0"
:class="
@@ -107,6 +112,7 @@ import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import {
get_default_user,
@@ -118,7 +124,6 @@ import {
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
@@ -185,6 +190,7 @@ defineExpose({
refreshValues,
setEquippedSkin,
setLoginDisabled,
login,
loginDisabled,
})
@@ -137,8 +137,8 @@ import {
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationProgressItem,
type PopupNotificationStandard,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
@@ -150,6 +150,7 @@ import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { toError } from '@/helpers/errors'
import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state'
@@ -352,14 +353,16 @@ function getDisplayIconUrl(icon: string | null | undefined): string | null {
return convertFileSrc(icon)
}
function getNotification(): PopupNotification | null {
function getNotification(): PopupNotificationStandard | null {
if (!notificationId.value) {
return null
}
const notification = popupNotificationManager
.getNotifications()
.find((notification) => notification.id === notificationId.value)
return notification ?? null
return notification?.contentType === 'standard' && notification.type === 'download'
? notification
: null
}
function removeNotification(): void {
@@ -377,11 +380,17 @@ function syncTerminalNotifications(): void {
for (const terminal of terminalNotifications) {
const popupId = terminalNotificationIds.get(terminal.id)
let notification = popupId
? popupNotificationManager.getNotifications().find((candidate) => candidate.id === popupId)
? popupNotificationManager
.getNotifications()
.find(
(candidate): candidate is PopupNotificationStandard =>
candidate.id === popupId && candidate.contentType === 'standard',
)
: undefined
if (!notification) {
notification = popupNotificationManager.addPopupNotification({
contentType: 'standard',
title: terminal.title,
text: terminal.text,
type: terminal.type,
@@ -411,7 +420,7 @@ function syncTerminalNotifications(): void {
function buildDownloadItems(): PopupNotificationProgressItem[] {
return [
...installJobNotifications.progressItems.value,
...currentLoadingBars.value.map((bar) => ({
...currentLoadingBars.value.map<PopupNotificationProgressItem>((bar) => ({
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
@@ -451,7 +460,7 @@ function updateNotification(resummon = false): void {
return
}
let notif = getNotification()
const notif = getNotification()
const progressItems = buildDownloadItems()
if (notif) {
@@ -463,7 +472,8 @@ function updateNotification(resummon = false): void {
notif.progress = undefined
notif.waiting = undefined
} else {
notif = popupNotificationManager.addPopupNotification({
const notification = popupNotificationManager.addPopupNotification({
contentType: 'standard',
title: installJobNotifications.active.value
? installJobNotifications.title.value
: formatMessage(messages.downloads),
@@ -471,7 +481,7 @@ function updateNotification(resummon = false): void {
autoCloseMs: null,
progressItems,
})
notificationId.value = notif.id
notificationId.value = notification.id
}
}
@@ -548,7 +558,7 @@ async function refreshLoadingBars() {
const installJobNotifications = await useInstallJobNotifications({
router,
handleError,
handleError: (error) => handleError(toError(error)),
onChange: updateNotification,
})
@@ -1,181 +0,0 @@
<template>
<transition name="fade">
<div
v-show="shown"
ref="contextMenu"
class="context-menu"
:style="{
left: left,
top: top,
}"
>
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
<hr v-if="option.type === 'divider'" class="divider" />
<div
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
class="item clickable"
:class="[option.color ?? 'base']"
>
<slot :name="option.name" />
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['menu-closed', 'option-clicked'])
const item = ref(null)
const contextMenu = ref(null)
const options = ref([])
const left = ref('0px')
const top = ref('0px')
const shown = ref(false)
defineExpose({
showMenu: (event, passedItem, passedOptions) => {
item.value = passedItem
options.value = passedOptions
// show to get dimensions
shown.value = true
// then, adjust position if overflowing
nextTick(() => {
const menuWidth = contextMenu.value?.clientWidth || 200
const menuHeight = contextMenu.value?.clientHeight || 100
const minFromEdge = 10
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
} else {
left.value = event.pageX + minFromEdge + 'px'
}
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
} else {
top.value = event.pageY + minFromEdge + 'px'
}
})
},
})
const isInstanceLink = (item) => {
if (item.instance != undefined && item.instance.link) {
return true
} else if (item != undefined && item.link) {
return true
}
return false
}
const hideContextMenu = () => {
shown.value = false
emit('menu-closed')
}
const optionClicked = (option) => {
emit('option-clicked', {
item: item.value,
option: option,
})
hideContextMenu()
}
const onEscKeyRelease = (event) => {
if (event.keyCode === 27) {
hideContextMenu()
}
}
const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
contextMenu.value &&
contextMenu.value.$el !== event.target &&
!elements.includes(contextMenu.value.$el)
) {
hideContextMenu()
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
document.body.addEventListener('keyup', onEscKeyRelease)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
document.removeEventListener('keyup', onEscKeyRelease)
})
</script>
<style lang="scss" scoped>
.context-menu {
background-color: var(--color-raised-bg);
border-radius: var(--radius-md);
box-shadow: var(--shadow-floating);
border: 1px solid var(--color-divider);
margin: 0;
position: fixed;
z-index: 1000000;
overflow: hidden;
padding: var(--gap-sm);
.item {
align-items: center;
color: var(--color-base);
cursor: pointer;
display: flex;
gap: var(--gap-sm);
padding: var(--gap-sm);
border-radius: var(--radius-sm);
&:hover,
&:active {
&.base {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
&.primary {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: bold;
}
&.danger {
background-color: var(--color-red);
color: var(--color-accent-contrast);
font-weight: bold;
}
&.contrast {
background-color: var(--color-orange);
color: var(--color-accent-contrast);
font-weight: bold;
}
}
}
.divider {
border: 1px solid var(--color-divider);
margin: var(--gap-sm);
pointer-events: none;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -20,11 +20,11 @@ import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install_existing_instance } from '@/helpers/install'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
@@ -7,8 +7,9 @@ import {
FileTreeSelect,
injectNotificationManager,
injectPopupNotificationManager,
Input,
NewModal,
StyledInput,
Textarea,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
@@ -194,7 +195,7 @@ function normalizeExportPath(path) {
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
<Input
v-model="nameInput"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
@@ -206,7 +207,7 @@ function normalizeExportPath(path) {
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<StyledInput
<Input
v-model="versionInput"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
@@ -219,9 +220,8 @@ function normalizeExportPath(path) {
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<StyledInput
<Textarea
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
@@ -7,22 +7,39 @@ import {
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import {
Avatar,
defineMessages,
IconButton,
injectNotificationManager,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const messages = defineMessages({
instanceIcon: { id: 'app.instance.card.icon-alt', defaultMessage: 'Instance icon' },
stop: { id: 'app.instance.card.stop', defaultMessage: 'Stop' },
loading: { id: 'app.instance.card.loading', defaultMessage: 'Instance is loading...' },
installing: { id: 'app.instance.card.installing', defaultMessage: 'Installing...' },
play: { id: 'app.instance.card.play', defaultMessage: 'Play' },
repair: { id: 'app.instance.card.repair', defaultMessage: 'Repair' },
played: { id: 'app.instance.card.played', defaultMessage: 'Played {relativeTime}' },
neverPlayed: { id: 'app.instance.card.never-played', defaultMessage: 'Never played' },
})
const props = defineProps({
instance: {
@@ -159,9 +176,9 @@ onMounted(() => {
>
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:src="getInstanceIconUrl(instance.icon_path)"
:tint-by="instance.id"
alt="Mod card"
:alt="formatMessage(messages.instanceIcon)"
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span>
@@ -169,10 +186,10 @@ onMounted(() => {
<div class="flex items-center">
<IconButton
v-if="playing"
v-tooltip="'Stop'"
v-tooltip="formatMessage(messages.stop)"
type="colored"
color="red"
:label="'Stop'"
:label="formatMessage(messages.stop)"
@mouseenter="checkProcess"
@click="(e) => stop(e, 'InstanceCard')"
>
@@ -180,18 +197,18 @@ onMounted(() => {
</IconButton>
<IconButton
v-else-if="modLoading"
v-tooltip="'Instance is loading...'"
:label="'Instance is loading...'"
v-tooltip="formatMessage(messages.loading)"
:label="formatMessage(messages.loading)"
disabled
>
<SpinnerIcon class="animate-spin" />
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="'Play'"
v-tooltip="formatMessage(messages.play)"
:type="first ? 'colored' : 'base'"
:color="first ? 'brand' : undefined"
label="Play"
:label="formatMessage(messages.play)"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
@@ -203,9 +220,13 @@ onMounted(() => {
<TimerIcon />
<span class="text-sm">
<template v-if="instance.last_played">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
{{
formatMessage(messages.played, {
relativeTime: formatRelativeTime(dayjs(instance.last_played).toISOString()),
})
}}
</template>
<template v-else> Never played </template>
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
</span>
</div>
</div>
@@ -219,19 +240,19 @@ onMounted(() => {
<div class="relative flex items-center justify-center">
<Avatar
size="48px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:src="getInstanceIconUrl(instance.icon_path)"
:tint-by="instance.id"
alt="Mod card"
:alt="formatMessage(messages.instanceIcon)"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div class="absolute inset-0 flex items-center justify-center">
<IconButton
v-if="playing"
v-tooltip="'Stop'"
v-tooltip="formatMessage(messages.stop)"
type="colored"
color="red"
size="xl"
:label="'Stop'"
:label="formatMessage(messages.stop)"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@@ -241,17 +262,17 @@ onMounted(() => {
</IconButton>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<IconButton
v-else-if="!installed && !instance.quarantined"
v-tooltip="'Repair'"
v-tooltip="formatMessage(messages.repair)"
type="colored"
color="brand"
size="xl"
:label="'Repair'"
:label="formatMessage(messages.repair)"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
@@ -259,11 +280,11 @@ onMounted(() => {
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="'Play'"
v-tooltip="formatMessage(messages.play)"
type="colored"
color="brand"
size="xl"
:label="'Play'"
:label="formatMessage(messages.play)"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
@@ -1,9 +1,18 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Avatar, ButtonLink, defineMessages, FormattedTag, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { getInstanceIconUrl } from '@/helpers/instance'
const { formatMessage } = useVIntl()
const messages = defineMessages({
backToInstance: {
id: 'app.instance.navigation.back-to-instance',
defaultMessage: 'Back to instance',
},
})
type Instance = {
game_version: string
loader: string
@@ -31,11 +40,7 @@ const instanceLink = computed(() => {
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
<span class="flex items-center gap-2">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:alt="instance.name"
size="48px"
/>
<Avatar :src="getInstanceIconUrl(instance.icon_path)" :alt="instance.name" size="48px" />
<span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast">
{{ instance.name }}
@@ -48,7 +53,9 @@ const instanceLink = computed(() => {
</span>
</span>
</router-link>
<ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </ButtonLink>
<ButtonLink :to="instanceLink">
<LeftArrowIcon /> {{ formatMessage(messages.backToInstance) }}
</ButtonLink>
</div>
</template>
@@ -2,7 +2,7 @@
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div :id="props.id" class="toggle-setting" :class="{ compact }">
<div class="input-with-status">
<StyledInput
<Input
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
@@ -98,13 +98,7 @@ import {
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
Button,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { Button, defineMessages, injectNotificationManager, Input, useVIntl } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -2,17 +2,16 @@
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { list } from '@/helpers/instance'
import { getInstanceIconUrl, list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
const APPROX_USED_VERTICAL_SPACE = 475 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
const STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
@@ -201,7 +200,7 @@ const dividerTooltip = computed(() => {
>
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:src="getInstanceIconUrl(instance.icon_path)"
size="28px"
:tint-by="instance.id"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ImportIcon, PlusIcon } from '@modrinth/assets'
import { Button, defineMessages, IntlFormatted, useVIntl } from '@modrinth/ui'
import { inject, onMounted, onUnmounted, ref } from 'vue'
import modrinthSocialIcon from '../../assets/welcome/modrinth-social-icon.png'
const showCreationModal = inject<() => void>('showCreationModal')
const showImportModal = inject<() => void>('showImportModal')
const { formatMessage } = useVIntl()
const messages = defineMessages({
welcomeTitle: {
id: 'app.welcome-screen.title',
defaultMessage: 'Welcome to Modrinth',
},
welcomeDescription: {
id: 'app.welcome-screen.description',
defaultMessage: 'Ready to start playing?',
},
createInstance: {
id: 'app.welcome-screen.create-instance',
defaultMessage: 'Create an instance',
},
quickCreateHint: {
id: 'app.welcome-screen.quick-create-hint',
defaultMessage: 'Press <shortcut>N</shortcut> to quick create an instance',
},
importPrompt: {
id: 'app.welcome-screen.import-prompt',
defaultMessage: 'Escaping another launcher?',
},
importFromLauncher: {
id: 'app.welcome-screen.import-from-launcher',
defaultMessage: 'Import from launcher',
},
})
const offline = ref(!navigator.onLine)
function handleOffline() {
offline.value = true
}
function handleOnline() {
offline.value = false
}
function handleQuickCreate(event: KeyboardEvent) {
const target = event.target as HTMLElement | null
if (
event.key.toLowerCase() !== 'n' ||
event.repeat ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
target?.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '')
) {
return
}
if (!offline.value) {
event.preventDefault()
showCreationModal?.()
}
}
onMounted(() => {
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
window.addEventListener('keydown', handleQuickCreate)
})
onUnmounted(() => {
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
window.removeEventListener('keydown', handleQuickCreate)
})
</script>
<template>
<div class="flex flex-col min-h-full px-6 pb-6 pt-16">
<div class="relative flex grow items-center justify-center">
<div class="relative isolate flex flex-col items-center gap-6">
<div
class="dot-pattern pointer-events-none absolute left-1/2 -top-52 -z-10 h-[29.875rem] w-[min(25.9375rem,80vw)] -translate-x-1/2 rounded-2xl [@media(max-height:700px)]:h-[23rem]"
aria-hidden="true"
/>
<div class="size-[6.25rem]">
<img :src="modrinthSocialIcon" alt="" class="pointer-events-none size-full" />
</div>
<div class="flex flex-col items-center gap-2">
<h1 class="m-0 flex items-center gap-2 text-2xl font-semibold leading-8 text-contrast">
{{ formatMessage(messages.welcomeTitle) }}
</h1>
<p class="m-0 text-center text-base leading-6 text-primary">
{{ formatMessage(messages.welcomeDescription) }}
</p>
</div>
<div class="flex w-72 flex-col items-center gap-4">
<Button
type="colored"
color="brand"
size="lg"
class="!shadow-none"
:disabled="offline"
@click="showCreationModal?.()"
>
<PlusIcon />
{{ formatMessage(messages.createInstance) }}
</Button>
<span class="flex items-center gap-1 text-sm leading-5 text-secondary">
<IntlFormatted :message-id="messages.quickCreateHint">
<template #shortcut="{ children }">
<kbd
class="inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-solid border-surface-5 bg-button-bg px-1 text-xs font-normal leading-4 text-primary"
>
<component :is="() => children" />
</kbd>
</template>
</IntlFormatted>
</span>
</div>
</div>
</div>
<div
class="flex flex-col h-max items-center justify-end gap-4 text-sm leading-5 text-secondary"
>
<span class="whitespace-nowrap">{{ formatMessage(messages.importPrompt) }}</span>
<Button size="lg" class="!font-medium" :disabled="offline" @click="showImportModal?.()">
<ImportIcon />
{{ formatMessage(messages.importFromLauncher) }}
</Button>
</div>
</div>
</template>
<style scoped>
.dot-pattern {
background-image: radial-gradient(
circle,
color-mix(in srgb, var(--color-text-primary) 25%, transparent) 0.5px,
transparent 0.75px
);
background-size: 0.5625rem 0.5625rem;
opacity: 0.8;
-webkit-mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
</style>
@@ -39,17 +39,17 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const themeStore = useTheming()
const appSettings = useAppSettings()
const nativeDecorations = ref(true)
const isMaximized = ref(false)
const os = ref('')
const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
const alwaysShowAppControls = computed(() => appSettings.getFeatureFlag('always_show_app_controls'))
const showControls = computed(
() =>
@@ -3,7 +3,7 @@
v-if="showUpdatePill"
type="outlined"
native-type="button"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out !text-brand [&>svg]:!text-brand !shadow-[inset_0_0_0_1px_var(--color-brand)] hover:!bg-brand focus-visible:!bg-brand hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out !text-brand [&>svg]:!text-inherit !shadow-[inset_0_0_0_1px_var(--color-brand)] hover:!bg-brand focus-visible:!bg-brand hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
:class="{
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
@@ -0,0 +1,236 @@
<template>
<Teleport to="#teleports">
<transition name="fade">
<div
v-show="shown && !isMobileActiveSubmenu"
ref="contextMenu"
class="context-menu"
:style="menuStyle"
role="menu"
@keydown="handleMenuKeydown"
@mousemove="(event) => handleMenuMouseMove(event, 'menu')"
>
<template v-for="(option, index) in options" :key="index">
<hr v-if="isDivider(option)" class="divider" />
<button
v-else-if="isOptionVisible(option)"
:ref="(element) => setOptionButtonRef(index, element)"
type="button"
class="item clickable"
:class="[
option.color ?? 'base',
{
active: index === activeOptionIndex,
'safe-triangle-hover': index === pendingOptionIndex,
},
]"
role="menuitem"
:aria-haspopup="hasChildren(option) ? 'menu' : undefined"
:aria-expanded="hasChildren(option) ? index === activeOptionIndex : undefined"
@click.stop="handleOptionClick(option, index)"
@focus="handleOptionFocus(option, index)"
@mouseenter="handleOptionMouseEnter(option, index)"
>
<span class="item-content"><slot :name="option.name" /></span>
<ChevronRightIcon v-if="hasChildren(option)" class="submenu-chevron" />
</button>
</template>
</div>
</transition>
</Teleport>
<Teleport to="#teleports">
<transition name="fade">
<div
v-if="shown && activeOption && hasSubmenuPosition"
ref="submenu"
class="context-menu submenu"
:style="submenuStyle"
role="menu"
@keydown="handleSubmenuKeydown"
@mouseenter="handleSubmenuMouseEnter"
@mouseleave="isCursorInsideSubmenu = false"
@mousemove="(event) => handleMenuMouseMove(event, 'submenu')"
>
<button
v-if="isMobileSubmenuLayout"
type="button"
class="item clickable base mobile-back"
@click.stop="returnToMenu"
>
<ChevronLeftIcon class="submenu-chevron" />
<span class="item-content"><slot :name="activeOption.name" /></span>
</button>
<template v-for="(option, index) in activeOption.children" :key="index">
<hr v-if="isDivider(option)" class="divider" />
<button
v-else-if="isOptionVisible(option)"
:ref="(element) => setSubmenuButtonRef(index, element)"
type="button"
class="item clickable"
:class="option.color ?? 'base'"
role="menuitem"
@click.stop="optionClicked(option.name)"
>
<span class="item-content"><slot :name="option.name" /></span>
</button>
</template>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import type { ContextMenuEmit } from './types'
import { useContextMenu } from './use-context-menu'
import { hasChildren, isDivider } from './utils'
const emit = defineEmits<ContextMenuEmit>()
const {
shown,
contextMenu,
submenu,
options,
menuStyle,
submenuStyle,
activeOption,
activeOptionIndex,
pendingOptionIndex,
hasSubmenuPosition,
isMobileSubmenuLayout,
isMobileActiveSubmenu,
isCursorInsideSubmenu,
isOptionVisible,
setOptionButtonRef,
setSubmenuButtonRef,
showMenu,
hideMenu,
handleOptionClick,
optionClicked,
handleOptionFocus,
handleOptionMouseEnter,
returnToMenu,
handleSubmenuMouseEnter,
handleMenuMouseMove,
handleMenuKeydown,
handleSubmenuKeydown,
} = useContextMenu(emit)
defineExpose({ showMenu, hideMenu })
</script>
<style lang="scss" scoped>
.context-menu {
background-color: var(--color-raised-bg);
border-radius: var(--radius-md);
box-shadow: var(--shadow-floating);
border: 1px solid var(--color-divider);
margin: 0;
position: fixed;
z-index: 1000000;
overflow: hidden;
padding: var(--gap-sm);
min-width: 12rem;
max-width: calc(100vw - 1.25rem);
&.submenu {
z-index: 1000001;
}
.item {
align-items: center;
background: transparent;
border: 0;
box-shadow: none;
color: var(--color-text-primary);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: inherit;
font-weight: 500;
gap: var(--gap-sm);
justify-content: space-between;
padding: var(--gap-sm);
border-radius: var(--radius-sm);
text-align: left;
width: 100%;
.item-content {
align-items: center;
display: flex;
gap: var(--gap-sm);
min-width: 0;
}
.submenu-chevron {
color: var(--color-text-secondary);
flex-shrink: 0;
height: 1.25rem;
width: 1.25rem;
margin-right: -0.25rem;
}
:deep(svg) {
color: var(--color-base);
}
&:hover:not(.safe-triangle-hover),
&:active,
&:focus-visible,
&.active {
:deep(svg) {
color: inherit;
}
&.base {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
&.primary {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: 500;
}
&.danger {
background-color: var(--color-red);
color: var(--color-accent-contrast);
font-weight: 500;
}
&.contrast {
background-color: var(--color-orange);
color: var(--color-accent-contrast);
font-weight: 500;
}
}
}
.mobile-back {
border-bottom: 1px solid var(--color-divider);
border-radius: 0;
margin: calc(var(--gap-sm) * -1) calc(var(--gap-sm) * -1) var(--gap-sm);
width: calc(100% + var(--gap-sm) * 2);
}
.divider {
border: 1px solid var(--color-divider);
margin: var(--gap-sm);
pointer-events: none;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,41 @@
import type { ComponentPublicInstance } from 'vue'
export type ContextMenuDivider = {
type: string
}
export type ContextMenuAction = {
name: string
color?: string
children?: ContextMenuOption[]
}
export type ContextMenuParentAction = ContextMenuAction & {
children: ContextMenuOption[]
}
export type ContextMenuOption = ContextMenuDivider | ContextMenuAction
export type ContextMenuSelection = {
item: unknown
option: string
}
export type ContextMenuEmit = {
(event: 'menu-closed'): void
(event: 'option-clicked', selection: ContextMenuSelection): void
}
export type Point = {
x: number
y: number
}
export type ViewportRect = {
width: number
height: number
offsetTop: number
offsetLeft: number
}
export type ButtonRefElement = Element | ComponentPublicInstance | null
@@ -0,0 +1,170 @@
import type { CSSProperties, Ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import type { ContextMenuParentAction, Point, ViewportRect } from './types'
const MENU_GAP = 8
const VIEWPORT_MARGIN = 10
type ContextMenuPositionOptions = {
shown: Ref<boolean>
activeOption: Ref<ContextMenuParentAction | null>
activeOptionIndex: Ref<number | null>
isMobileSubmenuLayout: Ref<boolean>
contextMenu: Ref<HTMLElement | null>
submenu: Ref<HTMLElement | null>
optionButtonRefs: Map<number, HTMLElement>
}
export function useContextMenuPosition({
shown,
activeOption,
activeOptionIndex,
isMobileSubmenuLayout,
contextMenu,
submenu,
optionButtonRefs,
}: ContextMenuPositionOptions) {
const menuStyle = ref<CSSProperties>({ left: '0px', top: '0px' })
const menuAnchor = ref<Point>({ x: 0, y: 0 })
const submenuPosition = ref<Point>({ x: 0, y: 0 })
const hasSubmenuPosition = ref(false)
let positionRafId: number | null = null
const submenuStyle = computed<CSSProperties>(() => {
if (isMobileSubmenuLayout.value) return menuStyle.value
return {
left: `${submenuPosition.value.x}px`,
top: `${submenuPosition.value.y}px`,
}
})
function updateMenuPosition(x: number, y: number) {
if (!contextMenu.value) return
const viewport = getViewportRect()
const menuRect = contextMenu.value.getBoundingClientRect()
const viewportLeft = viewport.offsetLeft
const viewportTop = viewport.offsetTop
const viewportRight = viewport.offsetLeft + viewport.width
const viewportBottom = viewport.offsetTop + viewport.height
const anchorX = x + viewport.offsetLeft
const anchorY = y + viewport.offsetTop
const left = Math.min(
Math.max(viewportLeft + VIEWPORT_MARGIN, anchorX + MENU_GAP),
Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - menuRect.width - VIEWPORT_MARGIN),
)
const top = Math.min(
Math.max(viewportTop + VIEWPORT_MARGIN, anchorY + MENU_GAP),
Math.max(viewportTop + VIEWPORT_MARGIN, viewportBottom - menuRect.height - VIEWPORT_MARGIN),
)
menuStyle.value = { left: `${left}px`, top: `${top}px` }
if (activeOption.value) scheduleSubmenuPositionUpdate()
}
function updateSubmenuPosition() {
if (!activeOption.value || activeOptionIndex.value === null) return false
if (isMobileSubmenuLayout.value) {
hasSubmenuPosition.value = true
return true
}
const optionButton = optionButtonRefs.get(activeOptionIndex.value)
if (!optionButton || !contextMenu.value) return false
const viewport = getViewportRect()
const buttonRect = optionButton.getBoundingClientRect()
const menuRect = contextMenu.value.getBoundingClientRect()
const submenuRect = submenu.value?.getBoundingClientRect()
const submenuWidth = submenuRect?.width ?? menuRect.width
const submenuHeight = submenuRect?.height ?? 100
const direction = getSubmenuOpenDirection(menuRect, submenuWidth, viewport)
const preferredLeft =
direction === 'right'
? buttonRect.right + MENU_GAP
: buttonRect.left - submenuWidth - MENU_GAP
const minLeft = viewport.offsetLeft + VIEWPORT_MARGIN
const maxLeft = Math.max(
minLeft,
viewport.offsetLeft + viewport.width - submenuWidth - VIEWPORT_MARGIN,
)
const minTop = viewport.offsetTop + VIEWPORT_MARGIN
const maxTop = Math.max(
minTop,
viewport.offsetTop + viewport.height - submenuHeight - VIEWPORT_MARGIN,
)
submenuPosition.value = {
x: Math.min(Math.max(minLeft, preferredLeft), maxLeft),
y: Math.min(Math.max(minTop, buttonRect.top), maxTop),
}
hasSubmenuPosition.value = true
return true
}
function scheduleSubmenuPositionUpdate(retries = 8) {
nextTick(() => {
if (!shown.value || !activeOption.value) return
const hasRenderedSubmenu = submenu.value !== null
if (updateSubmenuPosition()) {
if (!hasRenderedSubmenu) nextTick(updateSubmenuPosition)
return
}
if (retries > 0) setTimeout(() => scheduleSubmenuPositionUpdate(retries - 1), 0)
})
}
function schedulePositionUpdate() {
if (!shown.value || positionRafId !== null) return
positionRafId = window.requestAnimationFrame(() => {
positionRafId = null
updateMenuPosition(menuAnchor.value.x, menuAnchor.value.y)
})
}
function cancelPositionUpdate() {
if (positionRafId !== null) window.cancelAnimationFrame(positionRafId)
}
return {
menuStyle,
menuAnchor,
submenuStyle,
hasSubmenuPosition,
updateMenuPosition,
scheduleSubmenuPositionUpdate,
schedulePositionUpdate,
cancelPositionUpdate,
}
}
function getViewportRect(): ViewportRect {
const visualViewport = window.visualViewport
return {
width: visualViewport?.width ?? window.innerWidth,
height: visualViewport?.height ?? window.innerHeight,
offsetTop: visualViewport?.offsetTop ?? 0,
offsetLeft: visualViewport?.offsetLeft ?? 0,
}
}
function getSubmenuOpenDirection(
menuRect: DOMRect,
submenuWidth: number,
viewport: ViewportRect,
): 'left' | 'right' {
const viewportLeft = viewport.offsetLeft
const viewportRight = viewport.offsetLeft + viewport.width
const rightSpace = viewportRight - menuRect.right - MENU_GAP - VIEWPORT_MARGIN
const leftSpace = menuRect.left - viewportLeft - MENU_GAP - VIEWPORT_MARGIN
if (rightSpace >= submenuWidth) return 'right'
if (leftSpace >= submenuWidth) return 'left'
return rightSpace >= leftSpace ? 'right' : 'left'
}
@@ -0,0 +1,357 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type {
ButtonRefElement,
ContextMenuAction,
ContextMenuEmit,
ContextMenuOption,
ContextMenuParentAction,
Point,
} from './types'
import { useContextMenuPosition } from './use-context-menu-position'
import {
focusRelativeButton,
getFocusableButtons,
hasChildren,
isAction,
isInstanceLink,
isPointInTriangle,
} from './utils'
const MOBILE_SUBMENU_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
const CONTEXT_MENU_OPEN_EVENT = 'modrinth-context-menu-open'
export function useContextMenu(emit: ContextMenuEmit) {
const item = ref<unknown>(null)
const contextMenu = ref<HTMLElement | null>(null)
const submenu = ref<HTMLElement | null>(null)
const options = ref<ContextMenuOption[]>([])
const shown = ref(false)
const activeOptionIndex = ref<number | null>(null)
const pendingOptionIndex = ref<number | null>(null)
const isCursorInsideSubmenu = ref(false)
const isMobileSubmenuLayout = ref(false)
const lastMousePosition = ref<Point | null>(null)
const contextMenuId = Symbol()
const optionButtonRefs = new Map<number, HTMLElement>()
const submenuButtonRefs = new Map<number, HTMLElement>()
let previousMousePosition: Point | null = null
let pendingOptionTimeout: ReturnType<typeof setTimeout> | null = null
let mobileSubmenuMediaQuery: MediaQueryList | null = null
const activeOption = computed<ContextMenuParentAction | null>(() => {
if (activeOptionIndex.value === null) return null
const option = options.value[activeOptionIndex.value]
return option && isAction(option) && hasChildren(option) ? option : null
})
const {
menuStyle,
menuAnchor,
submenuStyle,
hasSubmenuPosition,
updateMenuPosition,
scheduleSubmenuPositionUpdate,
schedulePositionUpdate,
cancelPositionUpdate,
} = useContextMenuPosition({
shown,
activeOption,
activeOptionIndex,
isMobileSubmenuLayout,
contextMenu,
submenu,
optionButtonRefs,
})
const isMobileActiveSubmenu = computed(
() => isMobileSubmenuLayout.value && activeOption.value !== null && hasSubmenuPosition.value,
)
function isOptionVisible(option: ContextMenuOption): option is ContextMenuAction {
return isAction(option) && !(isInstanceLink(item.value) && option.name === 'add_content')
}
function setOptionButtonRef(index: number, element: ButtonRefElement) {
setButtonRef(optionButtonRefs, index, element)
}
function setSubmenuButtonRef(index: number, element: ButtonRefElement) {
setButtonRef(submenuButtonRefs, index, element)
}
function hideMenu() {
if (!shown.value) return
shown.value = false
deactivateSubmenu()
emit('menu-closed')
}
function showMenu(event: MouseEvent, passedItem: unknown, passedOptions: ContextMenuOption[]) {
window.dispatchEvent(new CustomEvent(CONTEXT_MENU_OPEN_EVENT, { detail: contextMenuId }))
item.value = passedItem
options.value = passedOptions
menuAnchor.value = { x: event.clientX, y: event.clientY }
shown.value = true
deactivateSubmenu()
syncMobileSubmenuLayout()
nextTick(() => updateMenuPosition(event.clientX, event.clientY))
}
function handleOptionClick(option: ContextMenuAction, index: number) {
if (hasChildren(option)) {
activateSubmenu(index)
return
}
optionClicked(option.name)
}
function optionClicked(option: string) {
emit('option-clicked', { item: item.value, option })
hideMenu()
}
function handleOptionFocus(option: ContextMenuAction, index: number) {
if (hasChildren(option) && !isMobileSubmenuLayout.value) {
activateSubmenu(index)
} else if (!hasChildren(option)) {
deactivateSubmenu()
}
}
function handleOptionMouseEnter(option: ContextMenuAction, index: number) {
if (isMobileSubmenuLayout.value) return
if (activeOptionIndex.value === null) {
if (hasChildren(option)) activateSubmenu(index)
return
}
if (activeOptionIndex.value === index) return
if (!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)) {
commitHoveredOption(index)
return
}
pendingOptionIndex.value = index
clearPendingOptionTimeout()
pendingOptionTimeout = setTimeout(() => {
if (pendingOptionIndex.value !== index) return
if (isCursorInsideSubmenu.value) {
pendingOptionIndex.value = null
return
}
commitHoveredOption(index)
}, 180)
}
function commitHoveredOption(index: number) {
const option = options.value[index]
if (option && hasChildren(option)) {
activateSubmenu(index)
} else {
deactivateSubmenu()
}
}
function activateSubmenu(index: number) {
clearPendingOptionTimeout()
pendingOptionIndex.value = null
activeOptionIndex.value = index
hasSubmenuPosition.value = false
scheduleSubmenuPositionUpdate()
}
function deactivateSubmenu() {
clearPendingOptionTimeout()
activeOptionIndex.value = null
pendingOptionIndex.value = null
hasSubmenuPosition.value = false
isCursorInsideSubmenu.value = false
lastMousePosition.value = null
previousMousePosition = null
}
function returnToMenu() {
const previousIndex = activeOptionIndex.value
deactivateSubmenu()
nextTick(() => {
if (previousIndex !== null) optionButtonRefs.get(previousIndex)?.focus()
})
}
function handleSubmenuMouseEnter() {
isCursorInsideSubmenu.value = true
clearPendingOptionTimeout()
pendingOptionIndex.value = null
}
function handleMenuMouseMove(event: MouseEvent, source: 'menu' | 'submenu') {
previousMousePosition = lastMousePosition.value
lastMousePosition.value = { x: event.clientX, y: event.clientY }
if (
source === 'menu' &&
pendingOptionIndex.value !== null &&
!isCursorAimingAtSubmenu(lastMousePosition.value, previousMousePosition)
) {
commitHoveredOption(pendingOptionIndex.value)
}
}
function isCursorAimingAtSubmenu(cursor: Point | null, origin: Point | null) {
const submenuRect = submenu.value?.getBoundingClientRect()
if (!submenuRect || !cursor || !origin) return false
const submenuTargetX =
origin.x <= submenuRect.left
? submenuRect.left
: origin.x >= submenuRect.right
? submenuRect.right
: cursor.x <= submenuRect.left
? submenuRect.left
: submenuRect.right
const upperTarget = { x: submenuTargetX, y: submenuRect.top - 20 }
const lowerTarget = { x: submenuTargetX, y: submenuRect.bottom + 20 }
return isPointInTriangle(cursor, origin, upperTarget, lowerTarget)
}
function handleMenuKeydown(event: KeyboardEvent) {
const buttons = getFocusableButtons(optionButtonRefs)
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
} else if (event.key === 'ArrowRight') {
const focusedIndex = [...optionButtonRefs.entries()].find(
([, button]) => button === document.activeElement,
)?.[0]
const focusedOption = focusedIndex === undefined ? undefined : options.value[focusedIndex]
if (focusedIndex !== undefined && focusedOption && hasChildren(focusedOption)) {
event.preventDefault()
activateSubmenu(focusedIndex)
nextTick(() => getFocusableButtons(submenuButtonRefs)[0]?.focus())
}
} else if (event.key === 'Escape') {
event.preventDefault()
hideMenu()
}
}
function handleSubmenuKeydown(event: KeyboardEvent) {
const buttons = getFocusableButtons(submenuButtonRefs)
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
focusRelativeButton(buttons, event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus()
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
returnToMenu()
} else if (event.key === 'Escape') {
event.preventDefault()
hideMenu()
}
}
function syncMobileSubmenuLayout(event?: MediaQueryListEvent) {
isMobileSubmenuLayout.value = event?.matches ?? mobileSubmenuMediaQuery?.matches ?? false
}
function handleDocumentKeydown(event: KeyboardEvent) {
if (shown.value && event.key === 'Escape') hideMenu()
}
function handleContextMenuOpen(event: Event) {
if (shown.value && event instanceof CustomEvent && event.detail !== contextMenuId) hideMenu()
}
function handleClickOutside(event: MouseEvent) {
const target = event.target
if (!(target instanceof Node)) return
if (!contextMenu.value?.contains(target) && !submenu.value?.contains(target)) hideMenu()
}
onMounted(() => {
mobileSubmenuMediaQuery = window.matchMedia(MOBILE_SUBMENU_LAYOUT_QUERY)
syncMobileSubmenuLayout()
mobileSubmenuMediaQuery.addEventListener('change', syncMobileSubmenuLayout)
window.addEventListener('click', handleClickOutside)
window.addEventListener('resize', schedulePositionUpdate)
window.addEventListener('scroll', schedulePositionUpdate, true)
window.visualViewport?.addEventListener('scroll', schedulePositionUpdate)
window.visualViewport?.addEventListener('resize', schedulePositionUpdate)
window.addEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
document.addEventListener('keydown', handleDocumentKeydown)
})
onBeforeUnmount(() => {
clearPendingOptionTimeout()
cancelPositionUpdate()
mobileSubmenuMediaQuery?.removeEventListener('change', syncMobileSubmenuLayout)
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', schedulePositionUpdate)
window.removeEventListener('scroll', schedulePositionUpdate, true)
window.visualViewport?.removeEventListener('scroll', schedulePositionUpdate)
window.visualViewport?.removeEventListener('resize', schedulePositionUpdate)
window.removeEventListener(CONTEXT_MENU_OPEN_EVENT, handleContextMenuOpen)
document.removeEventListener('keydown', handleDocumentKeydown)
})
function clearPendingOptionTimeout() {
if (!pendingOptionTimeout) return
clearTimeout(pendingOptionTimeout)
pendingOptionTimeout = null
}
return {
shown,
contextMenu,
submenu,
options,
menuStyle,
submenuStyle,
activeOption,
activeOptionIndex,
pendingOptionIndex,
hasSubmenuPosition,
isMobileSubmenuLayout,
isMobileActiveSubmenu,
isCursorInsideSubmenu,
isOptionVisible,
setOptionButtonRef,
setSubmenuButtonRef,
showMenu,
hideMenu,
handleOptionClick,
optionClicked,
handleOptionFocus,
handleOptionMouseEnter,
returnToMenu,
handleSubmenuMouseEnter,
handleMenuMouseMove,
handleMenuKeydown,
handleSubmenuKeydown,
}
}
function setButtonRef(
buttonRefs: Map<number, HTMLElement>,
index: number,
element: ButtonRefElement,
) {
if (element instanceof HTMLElement) {
buttonRefs.set(index, element)
} else {
buttonRefs.delete(index)
}
}
@@ -0,0 +1,59 @@
import type {
ContextMenuAction,
ContextMenuDivider,
ContextMenuOption,
ContextMenuParentAction,
Point,
} from './types'
export function isAction(option: ContextMenuOption): option is ContextMenuAction {
return 'name' in option
}
export function isDivider(option: ContextMenuOption): option is ContextMenuDivider {
return 'type' in option && option.type === 'divider'
}
export function hasChildren(option: ContextMenuOption): option is ContextMenuParentAction {
return isAction(option) && Boolean(option.children?.length)
}
export function isInstanceLink(value: unknown) {
if (!value || typeof value !== 'object') return false
if ('instance' in value) {
const instance = value.instance
return Boolean(instance && typeof instance === 'object' && 'link' in instance && instance.link)
}
return 'link' in value && Boolean(value.link)
}
export function getFocusableButtons(buttonRefs: Map<number, HTMLElement>) {
return [...buttonRefs.entries()]
.sort(([left], [right]) => left - right)
.map(([, button]) => button)
.filter((button) => button.offsetParent !== null)
}
export function focusRelativeButton(buttons: HTMLElement[], direction: 1 | -1) {
if (!buttons.length) return
const currentIndex = buttons.indexOf(document.activeElement as HTMLElement)
const nextIndex =
currentIndex === -1 ? (direction === 1 ? 0 : buttons.length - 1) : currentIndex + direction
buttons[(nextIndex + buttons.length) % buttons.length]?.focus()
}
export function isPointInTriangle(point: Point, a: Point, b: Point, c: Point) {
const area = triangleArea(a, b, c)
const area1 = triangleArea(point, b, c)
const area2 = triangleArea(a, point, c)
const area3 = triangleArea(a, b, point)
return Math.abs(area - (area1 + area2 + area3)) < 0.5
}
function triangleArea(a: Point, b: Point, c: Point) {
return Math.abs((a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)) / 2)
}
@@ -6,8 +6,8 @@ import {
defineMessages,
IconButton,
injectNotificationManager,
Input,
IntlFormatted,
StyledInput,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -15,17 +15,17 @@ import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const themeStore = useTheming()
const appSettings = useAppSettings()
const props = defineProps<{
credentials: ModrinthCredentials | null
@@ -39,11 +39,11 @@ type FriendsSectionCollapsedFlag =
| 'friends_pending_collapsed'
function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
return themeStore.getFeatureFlag(flag)
return appSettings.getFeatureFlag(flag)
}
function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
themeStore.featureFlags[flag] = collapsed
appSettings.featureFlags[flag] = collapsed
getSettings()
.then((settings) => {
settings.feature_flags[flag] = collapsed
@@ -250,7 +250,7 @@ const messages = defineMessages({
{{ formatMessage(messages.usernameDescription) }}
</p>
<div class="flex items-center gap-2 mt-4">
<StyledInput
<Input
v-model="username"
:icon="UserIcon"
type="text"
@@ -280,7 +280,7 @@ const messages = defineMessages({
>
<UserPlusIcon />
</IconButton>
<StyledInput
<Input
v-model="search"
:icon="SearchIcon"
type="text"
@@ -11,7 +11,7 @@ import {
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
@@ -1,17 +1,40 @@
<script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import { Admonition, Avatar, Button, injectNotificationManager, StyledInput } from '@modrinth/ui'
import {
Admonition,
Avatar,
Button,
defineMessages,
injectNotificationManager,
Input,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance'
import { getInstanceIconUrl, list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
import { instanceKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.instance.add-server.title', defaultMessage: 'Add server to instance' },
compatibilityWarning: {
id: 'app.instance.add-server.compatibility-warning',
defaultMessage: 'This server may not be compatible with all instances.',
},
searchPlaceholder: {
id: 'app.instance.add-server.search-placeholder',
defaultMessage: 'Search for an instance',
},
adding: { id: 'app.instance.add-server.adding', defaultMessage: 'Adding...' },
added: { id: 'app.instance.add-server.added', defaultMessage: 'Added' },
add: { id: 'app.instance.add-server.add', defaultMessage: 'Add' },
cancel: { id: 'app.instance.add-server.cancel', defaultMessage: 'Cancel' },
})
const queryClient = useQueryClient()
const modal = ref()
@@ -77,14 +100,14 @@ async function addServer(instance) {
</script>
<template>
<ModalWrapper ref="modal" header="Add server to instance">
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition type="warning" body="This server may not be compatible with all instances." />
<StyledInput
<Admonition type="warning" :body="formatMessage(messages.compatibilityWarning)" />
<Input
v-model="searchFilter"
:icon="SearchIcon"
type="search"
placeholder="Search for an instance"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
/>
<div class="max-h-[21rem] overflow-y-auto">
@@ -98,21 +121,22 @@ async function addServer(instance) {
:to="`/instance/${encodeURIComponent(instance.id)}`"
@click="modal.hide()"
>
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
class="mr-2 [--size:2rem]"
/>
<Avatar :src="getInstanceIconUrl(instance.icon_path)" class="mr-2 [--size:2rem]" />
{{ instance.name }}
</router-link>
<Button :disabled="instance.added || instance.adding" @click="addServer(instance)">
<PlusIcon v-if="!instance.added && !instance.adding" />
<CheckIcon v-else-if="instance.added" />
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
{{
formatMessage(
instance.adding ? messages.adding : instance.added ? messages.added : messages.add,
)
}}
</Button>
</div>
</div>
<div class="input-group push-right">
<Button @click="modal.hide()">Cancel</Button>
<Button @click="modal.hide()">{{ formatMessage(messages.cancel) }}</Button>
</div>
</div>
</ModalWrapper>
@@ -13,11 +13,10 @@
import { UnknownFileWarningModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { type FeatureFlag, useAppSettings } from '@/composables/use-app-settings.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming()
const appSettings = useAppSettings()
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const modal = useTemplateRef('modal')
@@ -34,7 +33,7 @@ function show(
fileName.value = selectedFileName
externalFilesInModpack.value = selectedExternalFiles
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
if (appSettings.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
void createInstance()
return
}
@@ -50,7 +49,7 @@ function reset() {
async function proceed(dontShowAgain: boolean) {
if (dontShowAgain) {
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
appSettings.featureFlags[skipUnknownPackWarningFeatureFlag] = true
const settings = await getSettings()
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
await setSettings(settings)
@@ -0,0 +1,515 @@
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
import backpack from '@/assets/instance-icons/backpack.png'
import beacon from '@/assets/instance-icons/beacon.png'
import blueShark from '@/assets/instance-icons/blue-shark.png'
import bookshelf from '@/assets/instance-icons/bookshelf.png'
import brownBear from '@/assets/instance-icons/brown-bear.png'
import cake from '@/assets/instance-icons/cake.png'
import campfire from '@/assets/instance-icons/campfire.png'
import chest from '@/assets/instance-icons/chest.png'
import cogwheel from '@/assets/instance-icons/cogwheel.png'
import commandBlock from '@/assets/instance-icons/command-block.png'
import cookingPot from '@/assets/instance-icons/cooking-pot.png'
import couch from '@/assets/instance-icons/couch.png'
import craftingTable from '@/assets/instance-icons/crafting-table.png'
import creeper from '@/assets/instance-icons/creeper.png'
import enchantingTable from '@/assets/instance-icons/enchanting-table.png'
import enderChest from '@/assets/instance-icons/ender-chest.png'
import enderDragon from '@/assets/instance-icons/ender-dragon.png'
import engine from '@/assets/instance-icons/engine.png'
import fabric from '@/assets/instance-icons/fabric.png'
import forge from '@/assets/instance-icons/forge.png'
import furnace from '@/assets/instance-icons/furnace.png'
import gizmo from '@/assets/instance-icons/gizmo.png'
import globe from '@/assets/instance-icons/globe.png'
import grassBlock from '@/assets/instance-icons/grass-block.png'
import lantern from '@/assets/instance-icons/lantern.png'
import moobloom from '@/assets/instance-icons/moobloom.png'
import mrPack from '@/assets/instance-icons/mr-pack.png'
import neoForge from '@/assets/instance-icons/neoforge.png'
import orb from '@/assets/instance-icons/orb.png'
import oxygenDistributor from '@/assets/instance-icons/oxygen-distributor.png'
import pancakes from '@/assets/instance-icons/pancakes.png'
import pickaxe from '@/assets/instance-icons/pickaxe.png'
import pokeBall from '@/assets/instance-icons/poke-ball.png'
import quilt from '@/assets/instance-icons/quilt.png'
import redstoneBlock from '@/assets/instance-icons/redstone-block.png'
import sculkSensor from '@/assets/instance-icons/sculk-sensor.png'
import skeleton from '@/assets/instance-icons/skeleton.png'
import skillet from '@/assets/instance-icons/skillet.png'
import slimeBlock from '@/assets/instance-icons/slime-block.png'
import spaceHelmet from '@/assets/instance-icons/space-helmet.png'
import stickyPiston from '@/assets/instance-icons/sticky-piston.png'
import sword from '@/assets/instance-icons/sword.png'
import terminal from '@/assets/instance-icons/terminal.png'
import tinyPotato from '@/assets/instance-icons/tiny-potato.png'
import tire from '@/assets/instance-icons/tire.png'
import tnt from '@/assets/instance-icons/tnt.png'
import wrench from '@/assets/instance-icons/wrench.png'
import wrenchRinth from '@/assets/instance-icons/wrench-rinth.png'
import zombie from '@/assets/instance-icons/zombie.png'
const names = defineMessages({
yellow: {
id: 'instance.icon-editor.background.yellow',
defaultMessage: 'Yellow',
},
green: {
id: 'instance.icon-editor.background.green',
defaultMessage: 'Green',
},
lime: {
id: 'instance.icon-editor.background.lime',
defaultMessage: 'Lime',
},
darkGreen: {
id: 'instance.icon-editor.background.dark-green',
defaultMessage: 'Dark green',
},
purple: {
id: 'instance.icon-editor.background.purple',
defaultMessage: 'Purple',
},
blue: {
id: 'instance.icon-editor.background.blue',
defaultMessage: 'Blue',
},
orange: {
id: 'instance.icon-editor.background.orange',
defaultMessage: 'Orange',
},
red: {
id: 'instance.icon-editor.background.red',
defaultMessage: 'Red',
},
rose: {
id: 'instance.icon-editor.background.rose',
defaultMessage: 'Rose',
},
pink: {
id: 'instance.icon-editor.background.pink',
defaultMessage: 'Pink',
},
indigo: {
id: 'instance.icon-editor.background.indigo',
defaultMessage: 'Indigo',
},
lavender: {
id: 'instance.icon-editor.background.lavender',
defaultMessage: 'Lavender',
},
lightGray: {
id: 'instance.icon-editor.background.light-gray',
defaultMessage: 'Light gray',
},
gray: {
id: 'instance.icon-editor.background.gray',
defaultMessage: 'Gray',
},
darkGray: {
id: 'instance.icon-editor.background.dark-gray',
defaultMessage: 'Dark gray',
},
backpack: { id: 'instance.icon-editor.symbol.backpack', defaultMessage: 'Backpack' },
beacon: { id: 'instance.icon-editor.symbol.beacon', defaultMessage: 'Beacon' },
blueShark: { id: 'instance.icon-editor.symbol.blue-shark', defaultMessage: 'Blue Shark' },
bookshelf: { id: 'instance.icon-editor.symbol.bookshelf', defaultMessage: 'Bookshelf' },
brownBear: { id: 'instance.icon-editor.symbol.brown-bear', defaultMessage: 'Brown Bear' },
cake: { id: 'instance.icon-editor.symbol.cake', defaultMessage: 'Cake' },
campfire: { id: 'instance.icon-editor.symbol.campfire', defaultMessage: 'Campfire' },
chest: { id: 'instance.icon-editor.symbol.chest', defaultMessage: 'Chest' },
cogwheel: { id: 'instance.icon-editor.symbol.cogwheel', defaultMessage: 'Cogwheel' },
commandBlock: {
id: 'instance.icon-editor.symbol.command-block',
defaultMessage: 'Command Block',
},
cookingPot: {
id: 'instance.icon-editor.symbol.cooking-pot',
defaultMessage: 'Cooking Pot',
},
couch: { id: 'instance.icon-editor.symbol.couch', defaultMessage: 'Couch' },
craftingTable: {
id: 'instance.icon-editor.symbol.crafting-table',
defaultMessage: 'Crafting Table',
},
creeper: { id: 'instance.icon-editor.symbol.creeper', defaultMessage: 'Creeper' },
enchantingTable: {
id: 'instance.icon-editor.symbol.enchanting-table',
defaultMessage: 'Enchanting Table',
},
enderChest: {
id: 'instance.icon-editor.symbol.ender-chest',
defaultMessage: 'Ender Chest',
},
enderDragon: {
id: 'instance.icon-editor.symbol.ender-dragon',
defaultMessage: 'Ender Dragon',
},
engine: { id: 'instance.icon-editor.symbol.engine', defaultMessage: 'Engine' },
furnace: { id: 'instance.icon-editor.symbol.furnace', defaultMessage: 'Furnace' },
gizmo: { id: 'instance.icon-editor.symbol.gizmo', defaultMessage: 'Gizmo' },
globe: { id: 'instance.icon-editor.symbol.globe', defaultMessage: 'Globe' },
grassBlock: {
id: 'instance.icon-editor.symbol.grass-block',
defaultMessage: 'Grass Block',
},
lantern: { id: 'instance.icon-editor.symbol.lantern', defaultMessage: 'Lantern' },
moobloom: { id: 'instance.icon-editor.symbol.moobloom', defaultMessage: 'Moobloom' },
mrPack: { id: 'instance.icon-editor.symbol.mr-pack', defaultMessage: 'Mr Pack' },
orb: { id: 'instance.icon-editor.symbol.orb', defaultMessage: 'Orb' },
oxygenDistributor: {
id: 'instance.icon-editor.symbol.oxygen-distributor',
defaultMessage: 'Oxygen Distributor',
},
pancakes: { id: 'instance.icon-editor.symbol.pancakes', defaultMessage: 'Pancakes' },
pickaxe: { id: 'instance.icon-editor.symbol.pickaxe', defaultMessage: 'Pickaxe' },
pokeBall: { id: 'instance.icon-editor.symbol.poke-ball', defaultMessage: 'Poke Ball' },
redstoneBlock: {
id: 'instance.icon-editor.symbol.redstone-block',
defaultMessage: 'Redstone Block',
},
sculkSensor: {
id: 'instance.icon-editor.symbol.sculk-sensor',
defaultMessage: 'Sculk Sensor',
},
skeleton: { id: 'instance.icon-editor.symbol.skeleton', defaultMessage: 'Skeleton' },
skillet: { id: 'instance.icon-editor.symbol.skillet', defaultMessage: 'Skillet' },
slimeBlock: {
id: 'instance.icon-editor.symbol.slime-block',
defaultMessage: 'Slime Block',
},
spaceHelmet: {
id: 'instance.icon-editor.symbol.space-helmet',
defaultMessage: 'Space Helmet',
},
stickyPiston: {
id: 'instance.icon-editor.symbol.sticky-piston',
defaultMessage: 'Sticky Piston',
},
sword: { id: 'instance.icon-editor.symbol.sword', defaultMessage: 'Sword' },
tnt: { id: 'instance.icon-editor.symbol.tnt', defaultMessage: 'TNT' },
terminal: { id: 'instance.icon-editor.symbol.terminal', defaultMessage: 'Terminal' },
tinyPotato: {
id: 'instance.icon-editor.symbol.tiny-potato',
defaultMessage: 'Tiny Potato',
},
tire: { id: 'instance.icon-editor.symbol.tire', defaultMessage: 'Tire' },
wrench: { id: 'instance.icon-editor.symbol.create-wrench', defaultMessage: 'Wrench' },
wrenchRinth: {
id: 'instance.icon-editor.symbol.wrenth-rinth',
defaultMessage: 'Modrinth Wrench',
},
zombie: { id: 'instance.icon-editor.symbol.zombie', defaultMessage: 'Zombie' },
fabric: { id: 'instance.icon-editor.symbol.fabric', defaultMessage: 'Fabric' },
forge: { id: 'instance.icon-editor.symbol.forge', defaultMessage: 'Forge' },
neoForge: { id: 'instance.icon-editor.symbol.neoforge', defaultMessage: 'NeoForge' },
quilt: { id: 'instance.icon-editor.symbol.quilt', defaultMessage: 'Quilt' },
})
export interface SymbolOption {
id: string
name: MessageDescriptor
asset: string
category: 'loader' | 'modded' | 'vanilla'
excludeFromRandomization?: boolean
}
export const backgroundOptions = [
{
id: 'rose',
background: {
type: 'linear-top-down-gradient',
top_color: '#D62E63',
bottom_color: '#F95C62',
},
name: names.rose,
},
{
id: 'orange',
background: {
type: 'linear-top-down-gradient',
top_color: '#FF8D29',
bottom_color: '#FFB452',
},
name: names.orange,
},
{
id: 'yellow',
background: {
type: 'linear-top-down-gradient',
top_color: '#FFC629',
bottom_color: '#FFEE53',
},
name: names.yellow,
},
{
id: 'lime',
background: {
type: 'linear-top-down-gradient',
top_color: '#6FDA1D',
bottom_color: '#CBFF50',
},
name: names.lime,
},
{
id: 'green',
background: {
type: 'linear-top-down-gradient',
top_color: '#0B9F21',
bottom_color: '#4FD24B',
},
name: names.green,
},
// {
// id: 'dark_green',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#084C13',
// bottom_color: '#327735',
// },
// name: names.darkGreen,
// },
// {
// id: 'indigo',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#3F00D3',
// bottom_color: '#2659FE',
// },
// name: names.indigo,
// },
{
id: 'purple',
background: {
type: 'linear-top-down-gradient',
top_color: '#4739FF',
bottom_color: '#6670FF',
},
name: names.purple,
},
{
id: 'blue',
background: {
type: 'linear-top-down-gradient',
top_color: '#227EFF',
bottom_color: '#5EC1FF',
},
name: names.blue,
},
{
id: 'lavender',
background: {
type: 'linear-top-down-gradient',
top_color: '#C056FD',
bottom_color: '#B889FF',
},
name: names.lavender,
},
{
id: 'pink',
background: {
type: 'linear-top-down-gradient',
top_color: '#F640C0',
bottom_color: '#FF7BF1',
},
name: names.pink,
},
// {
// id: 'red',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#F6111C',
// bottom_color: '#F94548',
// },
// name: names.red,
// },
{
id: 'light_gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#AEAEAE',
bottom_color: '#D9D9D9',
},
name: names.lightGray,
},
{
id: 'gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#373C4C',
bottom_color: '#4C4F58',
},
name: names.gray,
},
{
id: 'dark_gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#1B1D29',
bottom_color: '#252731',
},
name: names.darkGray,
},
] as const
export const symbolOptions = [
/////////////////////////
// loaders
/////////////////////////
{
id: 'fabric',
name: names.fabric,
asset: fabric,
category: 'loader',
excludeFromRandomization: true,
},
{
id: 'forge',
name: names.forge,
asset: forge,
category: 'loader',
excludeFromRandomization: true,
},
{
id: 'neoforge',
name: names.neoForge,
asset: neoForge,
category: 'loader',
excludeFromRandomization: true,
},
{
id: 'quilt',
name: names.quilt,
asset: quilt,
category: 'loader',
excludeFromRandomization: true,
},
// Cobblemon: Poké Ball
{ id: 'poke_ball', name: names.pokeBall, asset: pokeBall, category: 'modded' },
// Origins: Orb of Origins
{ id: 'orb', name: names.orb, asset: orb, category: 'modded' },
// Farmer's Delight: Cooking Pot, Skillet
{ id: 'cooking_pot', name: names.cookingPot, asset: cookingPot, category: 'modded' },
{ id: 'skillet', name: names.skillet, asset: skillet, category: 'modded' },
// Supplementaries: Globe, Pancakes
{ id: 'globe', name: names.globe, asset: globe, category: 'modded' },
{ id: 'pancakes', name: names.pancakes, asset: pancakes, category: 'modded' },
// Sophisticated Backpacks: Backpack
{ id: 'backpack', name: names.backpack, asset: backpack, category: 'modded' },
// Chipped: Chair
{ id: 'couch', name: names.couch, asset: couch, category: 'modded' },
// Botania: Tiny Potato
{ id: 'tiny_potato', name: names.tinyPotato, asset: tinyPotato, category: 'modded' },
// Blåhaj: Blue Shark
{ id: 'blue_shark', name: names.blueShark, asset: blueShark, category: 'modded' },
// Other modded symbols: Brown Bear, Moobloom
{ id: 'brown_bear', name: names.brownBear, asset: brownBear, category: 'modded' },
{ id: 'moobloom', name: names.moobloom, asset: moobloom, category: 'modded' },
// Create: Wrench, Cogwheel
{ id: 'create_wrench', name: names.wrench, asset: wrench, category: 'modded' },
{ id: 'cogwheel', name: names.cogwheel, asset: cogwheel, category: 'modded' },
// Create Aeronautics: Engine, Tire
{ id: 'engine', name: names.engine, asset: engine, category: 'modded' },
{ id: 'tire', name: names.tire, asset: tire, category: 'modded' },
// Ad Astra: Oxygen Distributor, Space Helmet
{
id: 'oxygen_distributor',
name: names.oxygenDistributor,
asset: oxygenDistributor,
category: 'modded',
},
{ id: 'space_helmet', name: names.spaceHelmet, asset: spaceHelmet, category: 'modded' },
// Miscellaneous: Gizmo, Terminal
{ id: 'gizmo', name: names.gizmo, asset: gizmo, category: 'modded' },
{ id: 'terminal', name: names.terminal, asset: terminal, category: 'modded' },
// Miscellaneous: Modrinth Wrench, Mr Pack
{ id: 'wrenth_rinth', name: names.wrenchRinth, asset: wrenchRinth, category: 'modded' },
{ id: 'mr_pack', name: names.mrPack, asset: mrPack, category: 'modded' },
/////////////////////////
// vanilla ones
/////////////////////////
{ id: 'grass_block', name: names.grassBlock, asset: grassBlock, category: 'vanilla' },
{ id: 'crafting_table', name: names.craftingTable, asset: craftingTable, category: 'vanilla' },
{ id: 'furnace', name: names.furnace, asset: furnace, category: 'vanilla' },
{ id: 'chest', name: names.chest, asset: chest, category: 'vanilla' },
{ id: 'bookshelf', name: names.bookshelf, asset: bookshelf, category: 'vanilla' },
{
id: 'redstone_block',
name: names.redstoneBlock,
asset: redstoneBlock,
category: 'vanilla',
},
{
id: 'sticky_piston',
name: names.stickyPiston,
asset: stickyPiston,
category: 'vanilla',
},
{ id: 'slime_block', name: names.slimeBlock, asset: slimeBlock, category: 'vanilla' },
{ id: 'cake', name: names.cake, asset: cake, category: 'vanilla' },
{ id: 'campfire', name: names.campfire, asset: campfire, category: 'vanilla' },
{ id: 'pickaxe', name: names.pickaxe, asset: pickaxe, category: 'vanilla' },
{ id: 'sword', name: names.sword, asset: sword, category: 'vanilla' },
{ id: 'zombie', name: names.zombie, asset: zombie, category: 'vanilla' },
{ id: 'creeper', name: names.creeper, asset: creeper, category: 'vanilla' },
{ id: 'skeleton', name: names.skeleton, asset: skeleton, category: 'vanilla' },
{ id: 'ender_dragon', name: names.enderDragon, asset: enderDragon, category: 'vanilla' },
{ id: 'ender_chest', name: names.enderChest, asset: enderChest, category: 'vanilla' },
{ id: 'sculk_sensor', name: names.sculkSensor, asset: sculkSensor, category: 'vanilla' },
{ id: 'beacon', name: names.beacon, asset: beacon, category: 'vanilla' },
{
id: 'enchanting_table',
name: names.enchantingTable,
asset: enchantingTable,
category: 'vanilla',
},
{ id: 'lantern', name: names.lantern, asset: lantern, category: 'vanilla' },
{ id: 'tnt', name: names.tnt, asset: tnt, category: 'vanilla' },
{ id: 'command_block', name: names.commandBlock, asset: commandBlock, category: 'vanilla' },
] as const satisfies readonly SymbolOption[]
export type BackgroundId = (typeof backgroundOptions)[number]['id']
export type SymbolId = (typeof symbolOptions)[number]['id']
export const RANDOM_CONFIG_BLACKLIST = [
{ background: 'purple', symbol: 'globe' },
{ background: 'blue', symbol: 'globe' },
{ background: 'gray', symbol: 'cogwheel' },
{ background: 'dark_gray', symbol: 'cogwheel' },
{ background: 'rose', symbol: 'poke_ball' },
{ background: 'lime', symbol: 'slime_block' },
{ background: 'green', symbol: 'slime_block' },
{ background: 'rose', symbol: 'redstone_block' },
{ background: 'rose', symbol: 'couch' },
{ background: 'orange', symbol: 'space_helmet' },
{ background: 'rose', symbol: 'tnt' },
{ background: 'yellow', symbol: 'moobloom' },
{ background: 'green', symbol: 'wrenth_rinth' },
{ background: 'lime', symbol: 'mr_pack' },
{ background: 'light_gray', symbol: 'skillet' },
{ background: 'light_gray', symbol: 'cooking_pot' },
] satisfies readonly { background: BackgroundId; symbol: SymbolId }[]
export const DEFAULT_BACKGROUND_ID = 'purple' satisfies BackgroundId
export const DEFAULT_SYMBOL_ID = 'grass_block' satisfies SymbolId
@@ -0,0 +1,540 @@
<script setup lang="ts">
import { CheckIcon, InfoIcon, RefreshCwIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { toError } from '@/helpers/errors'
import {
cache_generated_icon,
edit_generated_icon,
edit_generated_icon_if_empty,
get_recent_icon_configs,
} from '@/helpers/instance'
import type { IconBackground, InstanceIconConfig } from '@/helpers/types'
import {
type BackgroundId,
backgroundOptions,
DEFAULT_BACKGROUND_ID,
DEFAULT_SYMBOL_ID,
RANDOM_CONFIG_BLACKLIST,
type SymbolId,
type SymbolOption,
symbolOptions,
} from './editor-catalog'
const props = defineProps<{
instanceId?: string
config?: InstanceIconConfig | null
}>()
const emit = defineEmits<{
saved: [iconPath: string, config: InstanceIconConfig]
}>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const recentConfigs = ref<InstanceIconConfig[]>([])
const saving = ref(false)
const backgroundScroller = ref<HTMLElement | null>(null)
const showLeftBackgroundShadow = ref(false)
const showRightBackgroundShadow = ref(false)
const draggingBackgrounds = ref(false)
let backgroundScrollerResizeObserver: ResizeObserver | null = null
let backgroundDragPointerId: number | null = null
let backgroundDragCaptureTarget: Element | null = null
let backgroundDragStartX = 0
let backgroundDragStartScrollLeft = 0
let suppressBackgroundClick = false
let suppressBackgroundClickTimeout: ReturnType<typeof setTimeout> | null = null
const selectedBackground = ref<BackgroundId>(DEFAULT_BACKGROUND_ID)
const selectedSymbol = ref<SymbolId>(DEFAULT_SYMBOL_ID)
const selectedBackgroundOption = computed(
() => backgroundOptions.find((option) => option.id === selectedBackground.value)!,
)
const selectedSymbolOption = computed(
() => symbolOptions.find((option) => option.id === selectedSymbol.value)!,
)
const vanillaSymbolStartIndex = symbolOptions.findIndex((option) => option.category === 'vanilla')
const selectedConfig = computed<InstanceIconConfig>(() => ({
background: { ...selectedBackgroundOption.value.background },
symbol: selectedSymbol.value,
}))
const visibleRecentConfigs = computed(() =>
recentConfigs.value.filter(
(config) => backgroundOption(config.background) && symbolOption(config.symbol),
),
)
function updateBackgroundScrollShadows() {
const el = backgroundScroller.value
if (!el) {
showLeftBackgroundShadow.value = false
showRightBackgroundShadow.value = false
return
}
showLeftBackgroundShadow.value = el.scrollLeft > 0
showRightBackgroundShadow.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
}
function onBackgroundWheel(event: WheelEvent) {
const el = backgroundScroller.value
if (!el || el.scrollWidth <= el.clientWidth) return
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
el.scrollLeft += delta
}
function onBackgroundPointerDown(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerType === 'touch' || event.button !== 0) return
backgroundDragPointerId = event.pointerId
backgroundDragStartX = event.clientX
backgroundDragStartScrollLeft = el.scrollLeft
suppressBackgroundClick = false
backgroundDragCaptureTarget =
event.target instanceof Element ? (event.target.closest('button') ?? el) : el
backgroundDragCaptureTarget.setPointerCapture(event.pointerId)
}
function onBackgroundPointerMove(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerId !== backgroundDragPointerId) return
const distance = event.clientX - backgroundDragStartX
if (!draggingBackgrounds.value && Math.abs(distance) < 4) return
draggingBackgrounds.value = true
suppressBackgroundClick = true
event.preventDefault()
el.scrollLeft = backgroundDragStartScrollLeft - distance
}
function finishBackgroundDrag(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerId !== backgroundDragPointerId) return
if (backgroundDragCaptureTarget?.hasPointerCapture(event.pointerId)) {
backgroundDragCaptureTarget.releasePointerCapture(event.pointerId)
}
backgroundDragPointerId = null
backgroundDragCaptureTarget = null
draggingBackgrounds.value = false
if (suppressBackgroundClick) {
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
suppressBackgroundClickTimeout = setTimeout(() => {
suppressBackgroundClick = false
suppressBackgroundClickTimeout = null
}, 0)
}
}
function onBackgroundClick(event: MouseEvent) {
if (!suppressBackgroundClick) return
event.preventDefault()
event.stopPropagation()
suppressBackgroundClick = false
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
suppressBackgroundClickTimeout = null
}
onMounted(() => {
backgroundScrollerResizeObserver = new ResizeObserver(updateBackgroundScrollShadows)
if (backgroundScroller.value) backgroundScrollerResizeObserver.observe(backgroundScroller.value)
nextTick(updateBackgroundScrollShadows)
})
onBeforeUnmount(() => {
backgroundScrollerResizeObserver?.disconnect()
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
})
function backgroundOption(background?: IconBackground) {
if (background?.type !== 'linear-top-down-gradient') return undefined
return backgroundOptions.find(
(option) =>
option.background.top_color === background.top_color &&
option.background.bottom_color === background.bottom_color,
)
}
function backgroundStyle(background: IconBackground) {
if (background.type === 'color') return { backgroundColor: background.value }
return {
backgroundImage: `linear-gradient(to bottom, ${background.top_color}, ${background.bottom_color})`,
}
}
function backgroundKey(background: IconBackground) {
if (background.type === 'color') return `${background.type}-${background.value}`
return `${background.type}-${background.top_color}-${background.bottom_color}`
}
function symbolOption(symbol: string) {
return symbolOptions.find((option) => option.id === symbol)
}
function selectRecent(config: InstanceIconConfig) {
const background = backgroundOption(config.background)
const symbol = symbolOption(config.symbol)
if (!background || !symbol) return
selectedBackground.value = background.id
selectedSymbol.value = symbol.id
}
function surpriseMe() {
const configurations = backgroundOptions.flatMap((background) =>
symbolOptions
.filter(
(symbol: SymbolOption) =>
!symbol.excludeFromRandomization &&
background.id !== selectedBackground.value &&
symbol.id !== selectedSymbol.value &&
!RANDOM_CONFIG_BLACKLIST.some(
(config) => config.background === background.id && config.symbol === symbol.id,
),
)
.map((symbol) => ({ background: background.id, symbol: symbol.id })),
)
const configuration = configurations[Math.floor(Math.random() * configurations.length)]
if (!configuration) return
selectedBackground.value = configuration.background
selectedSymbol.value = configuration.symbol
}
async function loadRecents() {
try {
recentConfigs.value = await get_recent_icon_configs()
} catch (error) {
handleError(toError(error))
}
}
function show() {
selectedBackground.value = backgroundOption(props.config?.background)?.id ?? DEFAULT_BACKGROUND_ID
selectedSymbol.value = symbolOption(props.config?.symbol ?? '')?.id ?? DEFAULT_SYMBOL_ID
modal.value?.show()
void loadRecents()
nextTick(updateBackgroundScrollShadows)
}
function hide() {
modal.value?.hide()
}
async function loadSymbolBytes(asset: string): Promise<number[]> {
const response = await fetch(asset)
if (!response.ok) throw new Error('Failed to load the icon symbol.')
return Array.from(new Uint8Array(await response.arrayBuffer()))
}
async function saveIcon() {
if (saving.value) return
saving.value = true
try {
const config = selectedConfig.value
const symbolBytes = await loadSymbolBytes(selectedSymbolOption.value.asset)
const iconPath = props.instanceId
? await edit_generated_icon(props.instanceId, config, symbolBytes)
: await cache_generated_icon(config, symbolBytes, true)
emit('saved', iconPath, config)
saving.value = false
await nextTick()
hide()
} catch (error) {
handleError(toError(error))
} finally {
saving.value = false
}
}
async function randomizeAndSave() {
try {
surpriseMe()
const config = selectedConfig.value
const iconPath = await cache_generated_icon(
config,
await loadSymbolBytes(selectedSymbolOption.value.asset),
)
return { iconPath, config }
} catch (error) {
handleError(toError(error))
return null
}
}
async function applyGeneratedIcon(instanceId: string, config: InstanceIconConfig) {
try {
const symbol = symbolOption(config.symbol)
if (!backgroundOption(config.background) || !symbol) return false
await edit_generated_icon_if_empty(instanceId, config, await loadSymbolBytes(symbol.asset))
return true
} catch (error) {
handleError(toError(error))
return false
}
}
defineExpose({ show, hide, randomize: randomizeAndSave, randomizeAndSave, applyGeneratedIcon })
const messages = defineMessages({
title: {
id: 'instance.icon-editor.title',
defaultMessage: 'Icon editor',
},
background: {
id: 'instance.icon-editor.background',
defaultMessage: 'Background',
},
symbol: {
id: 'instance.icon-editor.symbol',
defaultMessage: 'Symbol',
},
surpriseMe: {
id: 'instance.icon-editor.surprise-me',
defaultMessage: 'Randomize',
},
recents: {
id: 'instance.icon-editor.recents',
defaultMessage: 'Recents',
},
description: {
id: 'instance.icon-editor.description',
defaultMessage: 'Mix and match elements to create a custom icon.',
},
saveIcon: {
id: 'instance.icon-editor.save',
defaultMessage: 'Save icon',
},
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="928px"
max-width="calc(100vw - 2rem)"
no-padding
actions-divider
:disable-close="saving"
>
<div class="flex h-[552px] max-h-[calc(100vh-168px)] min-h-0">
<aside
class="flex w-[244px] shrink-0 flex-col gap-4 overflow-y-auto border-0 border-r border-solid border-surface-5 p-6"
>
<div
class="flex w-full flex-col items-center gap-3.5 rounded-[20px] border border-solid border-surface-4 bg-surface-2 p-5"
>
<Avatar
:src="selectedSymbolOption.asset"
size="132px"
:style="backgroundStyle(selectedBackgroundOption.background)"
no-shadow
/>
<div class="flex items-center gap-2.5">
<Avatar
:src="selectedSymbolOption.asset"
size="40px"
:style="backgroundStyle(selectedBackgroundOption.background)"
no-shadow
/>
<Avatar
:src="selectedSymbolOption.asset"
size="30px"
:style="backgroundStyle(selectedBackgroundOption.background)"
no-shadow
/>
<Avatar
:src="selectedSymbolOption.asset"
size="20px"
:style="backgroundStyle(selectedBackgroundOption.background)"
no-shadow
/>
</div>
</div>
<Button class="w-full" @click="surpriseMe">
<RefreshCwIcon />
{{ formatMessage(messages.surpriseMe) }}
</Button>
<div v-if="visibleRecentConfigs.length" class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">{{ formatMessage(messages.recents) }}</span>
<div class="grid grid-cols-4 gap-3">
<button
v-for="(recentConfig, index) in visibleRecentConfigs"
:key="`${backgroundKey(recentConfig.background)}-${recentConfig.symbol}`"
class="icon-outline relative size-10 cursor-pointer overflow-hidden rounded-xl border-0 p-0 transition-transform hover:scale-105"
:style="backgroundStyle(recentConfig.background)"
:aria-label="`${formatMessage(messages.recents)} ${index + 1}`"
@click="selectRecent(recentConfig)"
>
<img
:src="symbolOption(recentConfig.symbol)?.asset"
alt=""
class="size-full object-cover"
/>
</button>
</div>
</div>
</aside>
<div class="min-w-0 flex-1 overflow-y-auto bg-surface-2">
<section class="border-0 border-b border-solid border-surface-5 p-4">
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.background) }}
</h3>
<div class="relative">
<div
class="background-scroll-shadow-left pointer-events-none absolute bottom-0 -left-0.5 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
:class="showLeftBackgroundShadow ? 'opacity-100' : 'opacity-0'"
/>
<div
ref="backgroundScroller"
class="flex w-full select-none gap-2.5 overflow-x-auto overflow-y-hidden pb-2 pr-6"
:class="{ 'cursor-grabbing': draggingBackgrounds }"
@pointerdown="onBackgroundPointerDown"
@pointermove="onBackgroundPointerMove"
@pointerup="finishBackgroundDrag"
@pointercancel="finishBackgroundDrag"
@click.capture="onBackgroundClick"
@wheel.prevent="onBackgroundWheel"
@scroll="updateBackgroundScrollShadows"
>
<button
v-for="option in backgroundOptions"
:key="option.id"
class="icon-option icon-outline relative aspect-square w-[calc((100%_-_3.125rem)/6)] shrink-0 cursor-pointer rounded-[20px] border-0 p-0"
:class="{ 'icon-outline-selected': selectedBackground === option.id }"
:style="backgroundStyle(option.background)"
:aria-label="formatMessage(option.name)"
:aria-pressed="selectedBackground === option.id"
@click="selectedBackground = option.id"
>
<span
v-if="selectedBackground === option.id"
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
>
<CheckIcon class="size-4" />
</span>
</button>
</div>
<div
class="background-scroll-shadow-right pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
:class="showRightBackgroundShadow ? 'opacity-100' : 'opacity-0'"
/>
</div>
</section>
<section class="p-4">
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.symbol) }}
</h3>
<div class="grid grid-cols-6 gap-2.5">
<template v-for="(option, index) in symbolOptions" :key="option.id">
<hr
v-if="index === vanillaSymbolStartIndex"
class="col-span-6 my-2.5 mx-1 w-full border-0 border-t border-solid border-surface-5"
/>
<button
v-tooltip="{
content: formatMessage(option.name),
delay: { show: 500, hide: 0 },
}"
class="icon-option icon-outline relative aspect-square cursor-pointer overflow-hidden rounded-[20px] border-0 bg-transparent p-0"
:class="{ 'icon-outline-selected': selectedSymbol === option.id }"
:aria-label="formatMessage(option.name)"
:aria-pressed="selectedSymbol === option.id"
@click="selectedSymbol = option.id"
>
<img :src="option.asset" alt="" class="size-full object-cover" />
<span
v-if="selectedSymbol === option.id"
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
>
<CheckIcon class="size-4" />
</span>
</button>
</template>
</div>
</section>
</div>
</div>
<template #actions>
<div class="flex items-center justify-between gap-4 px-2">
<div class="flex min-w-0 items-center gap-2 text-primary">
<InfoIcon class="size-6 shrink-0 text-blue" />
<span>{{ formatMessage(messages.description) }}</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button :disabled="saving" type="outlined" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="saving" @click="saveIcon">
<SpinnerIcon v-if="saving" class="animate-spin" />
<SaveIcon v-else />
{{ formatMessage(messages.saveIcon) }}
</Button>
</div>
</div>
</template>
</NewModal>
</template>
<style scoped>
.icon-outline {
outline: 1px solid color-mix(in srgb, var(--color-text-primary) 15%, transparent);
outline-offset: -1px;
}
.icon-option:not(.icon-outline-selected):hover {
outline-color: color-mix(in srgb, var(--color-text-primary) 30%, transparent);
}
.icon-option {
transition: outline-color 150ms ease;
}
.cursor-grabbing,
.cursor-grabbing * {
cursor: grabbing !important;
}
.icon-outline-selected {
outline-color: color-mix(in srgb, var(--color-text-primary) 60%, transparent);
}
.background-scroll-shadow-left {
-webkit-mask-image: linear-gradient(to right, black, transparent);
mask-image: linear-gradient(to right, black, transparent);
}
.background-scroll-shadow-right {
-webkit-mask-image: linear-gradient(to left, black, transparent);
mask-image: linear-gradient(to left, black, transparent);
}
</style>
@@ -0,0 +1,206 @@
<template>
<FloatingActionBar
:shown="selectedInstanceCount > 0"
:aria-label="formatMessage(messages.ariaLabel)"
hide-when-modal-open
>
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
{{ formatMessage(messages.selectedCount, { count: selectedLibraryInstances.size }) }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<Button
type="quiet"
class="!text-primary"
:disabled="busy"
@click="clearLibraryInstanceSelection"
>
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
</Button>
</div>
<div class="ml-auto flex items-center gap-0.5">
<Button
v-if="displayState.group === 'Group'"
type="quiet"
:disabled="busy"
@click="createGroupFromSelection"
>
<SquarePlusIcon />
<span class="bar-label">{{ formatMessage(messages.newGroup) }}</span>
</Button>
<Button
v-if="selectedGroupedInstances.length > 0"
type="quiet"
:disabled="busy"
@click="removeSelectedInstancesFromGroups"
>
<MinusIcon />
<span class="bar-label">{{ formatMessage(messages.removeFromGroup) }}</span>
</Button>
<div class="mx-1 h-6 w-px bg-surface-5" />
<Button
v-tooltip="deleting ? formatMessage(messages.deleting) : undefined"
type="quiet"
color="red"
interaction="filled"
:disabled="busy"
@click="confirmDeleteModal?.show()"
>
<TrashIcon />
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
</Button>
</div>
</FloatingActionBar>
<ConfirmDeleteInstanceModal
ref="confirmDeleteModal"
:instances="selectedInstances"
@delete="deleteSelectedInstances"
/>
</template>
<script setup lang="ts">
import { MinusIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
FloatingActionBar,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { toError } from '@/helpers/errors'
import { remove } from '@/helpers/instance'
import { set_group_memberships as setInstanceGroupMemberships } from '@/helpers/instance-groups'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const {
instances,
selectedLibraryInstances,
clearLibraryInstanceSelection,
setSelectedLibraryInstances,
creatingGroup,
createDefaultGroup,
displayState,
} = useLibrary()
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const deleting = ref(false)
const removingFromGroup = ref(false)
const selectedInstanceCount = computed(() => selectedLibraryInstances.value.size)
const selectedInstanceIds = computed(
() =>
new Set([...selectedLibraryInstances.value.values()].map((selection) => selection.instanceId)),
)
const selectedInstances = computed(() =>
instances.value.filter((instance) => selectedInstanceIds.value.has(instance.id)),
)
const selectedGroupedInstances = computed(() =>
[...selectedLibraryInstances.value.values()].flatMap((selection) => {
const instance = instances.value.find((candidate) => candidate.id === selection.instanceId)
return instance?.group_ids.includes(selection.groupId) ? [{ instance, selection }] : []
}),
)
const busy = computed(() => deleting.value || removingFromGroup.value || creatingGroup.value)
const messages = defineMessages({
ariaLabel: {
id: 'app.library.selection.aria-label',
defaultMessage: 'Selected instances',
},
selectedCount: {
id: 'app.library.selection.selected-count',
defaultMessage: '{count} selected',
},
deleting: {
id: 'app.library.selection.deleting',
defaultMessage: 'Deleting selected instances',
},
newGroup: {
id: 'app.library.selection.new-group',
defaultMessage: 'New group',
},
removeFromGroup: {
id: 'app.library.selection.remove-from-group',
defaultMessage: 'Remove from group',
},
})
async function createGroupFromSelection() {
if (busy.value) return
const instanceIds = selectedInstanceIds.value
clearLibraryInstanceSelection()
await createDefaultGroup(instanceIds)
}
async function removeSelectedInstancesFromGroups() {
if (busy.value || selectedGroupedInstances.value.length === 0) return
removingFromGroup.value = true
const selectedGroupIdsByInstanceId = new Map<string, Set<string>>()
for (const { instance, selection } of selectedGroupedInstances.value) {
const groupIds = selectedGroupIdsByInstanceId.get(instance.id) ?? new Set()
groupIds.add(selection.groupId)
selectedGroupIdsByInstanceId.set(instance.id, groupIds)
}
const operations = [...selectedGroupIdsByInstanceId].flatMap(([instanceId, groupIds]) => {
const instance = instances.value.find((candidate) => candidate.id === instanceId)
return instance ? [{ instance, groupIds }] : []
})
try {
await setInstanceGroupMemberships(
operations.map(({ instance, groupIds }) => ({
instance_id: instance.id,
group_ids: instance.group_ids.filter((groupId) => !groupIds.has(groupId)),
})),
)
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
for (const { instance, groupIds } of operations) {
for (const groupId of groupIds) {
nextSelectedInstances.delete(
getLibraryInstanceSelectionKey({
instanceId: instance.id,
groupId,
}),
)
}
}
setSelectedLibraryInstances(nextSelectedInstances.values())
} catch (error) {
handleError(toError(error))
} finally {
removingFromGroup.value = false
}
}
async function deleteSelectedInstances() {
if (busy.value || selectedInstanceCount.value === 0) return
deleting.value = true
const instanceIds = [...selectedInstanceIds.value]
const results = await Promise.allSettled(instanceIds.map((instanceId) => remove(instanceId)))
const deletedInstanceIds = new Set<string>()
for (const [index, result] of results.entries()) {
if (result.status === 'rejected') {
handleError(toError(result.reason))
} else {
deletedInstanceIds.add(instanceIds[index])
}
}
setSelectedLibraryInstances(
[...selectedLibraryInstances.value.values()].filter(
(selection) => !deletedInstanceIds.has(selection.instanceId),
),
)
deleting.value = false
}
</script>
@@ -0,0 +1,161 @@
<template>
<NewModal
ref="modal"
no-padding
scrollable
actions-divider
max-width="560px"
width="560px"
:on-hide="closeGroupInstancesModal"
>
<template #title>
<span class="text-2xl font-semibold text-contrast">
{{
formatMessage(messages.title, {
groupName:
groupInstancesModalGroup?.id === 'group:none'
? formatMessage(messages.ungrouped)
: (groupInstancesModalGroup?.name ?? ''),
})
}}
</span>
</template>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
<div class="px-6">
<Input
v-model="groupInstancesSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="w-full"
/>
</div>
<div
v-if="groupInstances.length === 0"
class="flex items-center justify-center py-12 text-secondary"
>
{{ formatMessage(messages.noInstancesFound) }}
</div>
<div v-else class="flex flex-col gap-1">
<div
v-for="instance in groupInstances"
:key="instance.id"
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
:class="{ 'opacity-60': selectedGroupInstanceIds.has(instance.id) }"
>
<div class="flex min-w-0 items-center gap-2.5">
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
:tint-by="instance.id"
:alt="instance.name"
size="2rem"
rounded="md"
/>
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
</div>
<Button
:type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
:disabled="
groupInstancesModalGroup?.id === 'group:none' &&
selectedGroupInstanceIds.has(instance.id)
"
@click="toggleGroupInstance(instance.id)"
>
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
{{
formatMessage(
selectedGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
)
}}
</Button>
</div>
</div>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button type="outlined" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="savingGroupInstances" @click="save">
<SpinnerIcon v-if="savingGroupInstances" class="animate-spin" />
<CheckIcon v-else />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
commonMessages,
defineMessages,
Input,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { useLibrary } from '@/components/ui/library/use-library'
import { getInstanceIconUrl } from '@/helpers/instance'
const { formatMessage } = useVIntl()
const messages = defineMessages({
ungrouped: {
id: 'app.library.group.ungrouped',
defaultMessage: 'Ungrouped',
},
title: {
id: 'app.library.group.instances-modal.title',
defaultMessage: 'Add instances to "{groupName}"',
},
searchPlaceholder: {
id: 'app.library.group.instances-modal.search-placeholder',
defaultMessage: 'Search instance',
},
noInstancesFound: {
id: 'app.library.group.instances-modal.no-instances-found',
defaultMessage: 'No instances found',
},
add: {
id: 'app.library.group.instances-modal.add',
defaultMessage: 'Add',
},
added: {
id: 'app.library.group.instances-modal.added',
defaultMessage: 'Added',
},
})
const {
isGroupInstancesModalOpen,
groupInstancesModalGroup,
groupInstancesSearch,
groupInstances,
selectedGroupInstanceIds,
savingGroupInstances,
closeGroupInstancesModal,
toggleGroupInstance,
saveGroupInstances,
} = useLibrary()
const modal = ref<InstanceType<typeof NewModal>>()
watch(isGroupInstancesModalOpen, (open) => {
if (open) {
modal.value?.show()
}
})
async function save() {
if (await saveGroupInstances()) {
modal.value?.hide()
}
}
</script>
@@ -0,0 +1,483 @@
<script setup lang="ts">
import {
ClipboardCopyIcon,
EditIcon,
EyeIcon,
FolderOpenIcon,
MinusIcon,
PaletteIcon,
PlayIcon,
PlusIcon,
StarIcon,
StopCircleIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
import Draggable from 'vuedraggable'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
import LibraryToolbar from '@/components/ui/library/library-toolbar/index.vue'
import LibrarySelectionActionBar from '@/components/ui/library/LibrarySelectionActionBar.vue'
import {
getLibraryInstanceSelectionKey,
type InstanceGroup as InstanceGroupType,
provideLibrary,
} from '@/components/ui/library/use-library'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { FAVORITES_GROUP_ID } from '@/helpers/instance-groups'
import type { GameInstance } from '@/helpers/types'
const props = defineProps<{
instances: GameInstance[]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
library: { id: 'app.library.title', defaultMessage: 'Library' },
noSearchResults: {
id: 'app.library.search.no-results.title',
defaultMessage: 'No instances match your search.',
},
play: { id: 'app.library.instance.action.play', defaultMessage: 'Play' },
stop: { id: 'app.library.instance.action.stop', defaultMessage: 'Stop' },
addToFavorites: {
id: 'app.library.instance.action.add-to-favorites',
defaultMessage: 'Add to favorites',
},
removeFromFavorites: {
id: 'app.library.instance.action.remove-from-favorites',
defaultMessage: 'Remove from favorites',
},
addContent: { id: 'app.library.instance.action.add-content', defaultMessage: 'Add content' },
viewInstance: {
id: 'app.library.instance.action.view-instance',
defaultMessage: 'View instance',
},
editIcon: {
id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon',
},
selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon',
},
createIcon: {
id: 'instance.settings.tabs.general.edit-icon.create',
defaultMessage: 'Create an icon',
},
editCreatedIcon: {
id: 'instance.settings.tabs.general.edit-icon.edit-created',
defaultMessage: 'Edit icon',
},
removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon',
},
duplicateInstance: {
id: 'app.library.instance.action.duplicate',
defaultMessage: 'Duplicate instance',
},
delete: { id: 'app.library.instance.action.delete', defaultMessage: 'Delete' },
openFolder: { id: 'app.library.instance.action.open-folder', defaultMessage: 'Open folder' },
copyPath: { id: 'app.library.instance.action.copy-path', defaultMessage: 'Copy path' },
removeFromGroup: {
id: 'app.library.instance.action.remove-from-group',
defaultMessage: 'Remove from group',
},
})
const {
instanceGroups,
libraryGroupsLoaded,
isSearching,
displayState,
filters,
reorderingGroups,
reorderGroups,
instanceOptions,
confirmDeleteModal,
iconEditorModal,
currentIconEditorInstance,
currentDeleteInstances,
clearLibraryInstanceSelection,
deleteInstance,
handleInstanceOption,
handleInstanceIconSaved,
selectedLibraryInstances,
setSelectedLibraryInstances,
toggleLibraryInstanceSelection,
} = provideLibrary(toRef(props, 'instances'))
const hasActiveFilters = computed(() =>
Object.values(filters.value).some((selectedValues) => selectedValues.length > 0),
)
const visibleInstanceGroups = computed(() =>
instanceGroups.value.filter((instanceGroup) =>
instanceGroup.id === FAVORITES_GROUP_ID
? instanceGroup.instances.length > 0
: instanceGroup.instances.length > 0 ||
(!isSearching.value && !hasActiveFilters.value && instanceGroup.key !== 'None'),
),
)
const visibleReorderableGroups = computed(() =>
displayState.value.group === 'Group'
? visibleInstanceGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID)
: [],
)
const visibleFavoritesGroup = computed(() =>
visibleInstanceGroups.value.find((group) => group.id === FAVORITES_GROUP_ID),
)
const draggableGroups = ref<InstanceGroupType[]>([])
const libraryGroupsContainer = ref<HTMLElement>()
const isDraggingGroup = ref(false)
const GROUP_REORDERING_CLASS = 'instance-group-reordering'
const canDragReorderGroups = computed(
() => !reorderingGroups.value && draggableGroups.value.length > 1,
)
watch(
visibleReorderableGroups,
(groups) => {
if (!isDraggingGroup.value) {
const previousGroupTops = getReorderableGroupTops()
draggableGroups.value = [...groups]
void nextTick(() => animateGroupReorder(previousGroupTops))
}
},
{ immediate: true },
)
function getReorderableGroupTops() {
const groupTops = new Map<string, number>()
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
'[data-instance-group-reorder-id]',
)
for (const groupElement of groupElements ?? []) {
const groupId = groupElement.dataset.instanceGroupReorderId
if (groupId) {
groupTops.set(groupId, groupElement.getBoundingClientRect().top)
}
}
return groupTops
}
function animateGroupReorder(previousGroupTops: Map<string, number>) {
if (
previousGroupTops.size === 0 ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
return
}
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
'[data-instance-group-reorder-id]',
)
for (const groupElement of groupElements ?? []) {
const groupId = groupElement.dataset.instanceGroupReorderId
const previousTop = groupId ? previousGroupTops.get(groupId) : undefined
if (previousTop === undefined) continue
const offset = previousTop - groupElement.getBoundingClientRect().top
if (Math.abs(offset) < 1) continue
groupElement.animate(
[{ transform: `translateY(${offset}px)` }, { transform: 'translateY(0)' }],
{ duration: 200, easing: 'ease-out' },
)
}
}
function onGroupDragStart() {
isDraggingGroup.value = true
document.documentElement.classList.add(GROUP_REORDERING_CLASS)
}
function onGroupDragEnd() {
isDraggingGroup.value = false
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
const currentGroupIds = visibleReorderableGroups.value.map((group) => group.id)
const orderedGroupIds = draggableGroups.value.map((group) => group.id)
if (orderedGroupIds.every((groupId, index) => groupId === currentGroupIds[index])) {
draggableGroups.value = [...visibleReorderableGroups.value]
return
}
void reorderGroups(orderedGroupIds)
}
onUnmounted(() => {
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
})
onDeactivated(clearLibraryInstanceSelection)
const anchorInstance = ref<{ groupId: string; instanceId: string } | null>(null)
function handleToggleInstance(groupId: string, instanceId: string, shiftKey: boolean) {
const displayedInstances = visibleInstanceGroups.value.flatMap((group) =>
group.instances.map((instance) => ({
groupId: group.id,
instanceId: instance.id,
})),
)
const anchor = anchorInstance.value
if (shiftKey && anchor && displayedInstances.length) {
const anchorIndex = displayedInstances.findIndex(
(instance) =>
instance.groupId === anchor.groupId && instance.instanceId === anchor.instanceId,
)
const targetIndex = displayedInstances.findIndex(
(instance) => instance.groupId === groupId && instance.instanceId === instanceId,
)
if (anchorIndex === -1 || targetIndex === -1) {
toggleLibraryInstanceSelection({ groupId, instanceId })
return
}
const start = Math.min(anchorIndex, targetIndex)
const end = Math.max(anchorIndex, targetIndex)
const range = displayedInstances.slice(start, end + 1)
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
const targetKey = getLibraryInstanceSelectionKey({ groupId, instanceId })
if (nextSelectedInstances.has(targetKey)) {
for (const instance of range) {
nextSelectedInstances.delete(getLibraryInstanceSelectionKey(instance))
}
} else {
for (const instance of range) {
nextSelectedInstances.set(getLibraryInstanceSelectionKey(instance), instance)
}
}
setSelectedLibraryInstances(nextSelectedInstances.values())
anchorInstance.value = null
return
}
toggleLibraryInstanceSelection({ groupId, instanceId })
anchorInstance.value = { groupId, instanceId }
}
function setInstanceOptions(component: unknown) {
instanceOptions.value = component as InstanceType<typeof ContextMenu> | null
}
function setConfirmDeleteModal(component: unknown) {
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
}
function setIconEditorModal(component: unknown) {
iconEditorModal.value = component as InstanceType<typeof IconEditorModal> | null
}
watch(selectedLibraryInstances, (selectedInstances) => {
if (selectedInstances.size === 0) {
anchorInstance.value = null
return
}
if (
anchorInstance.value &&
!selectedInstances.has(getLibraryInstanceSelectionKey(anchorInstance.value))
) {
anchorInstance.value = null
}
})
</script>
<template>
<InstanceGroupDnd :instances="instances">
<section data-library-page-background class="flex flex-col gap-3 pb-16 min-h-[500px]">
<h2 class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.library) }}
</h2>
<LibraryToolbar />
<div
v-if="libraryGroupsLoaded && isSearching && visibleInstanceGroups.length === 0"
class="text-base text-primary"
>
{{ formatMessage(messages.noSearchResults) }}
</div>
<Transition
v-else
enter-active-class="transition-opacity duration-200 ease-out motion-reduce:transition-none"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
>
<div
v-if="libraryGroupsLoaded && displayState.group === 'Group'"
ref="libraryGroupsContainer"
data-library-page-background
class="flex flex-col"
>
<div v-if="visibleFavoritesGroup" class="min-w-0">
<InstanceGroup
:instance-group="visibleFavoritesGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === FAVORITES_GROUP_ID ? anchorInstance.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance(FAVORITES_GROUP_ID, instanceId, shiftKey)
"
/>
</div>
<Draggable
:list="draggableGroups"
class="flex flex-col"
item-key="id"
:disabled="!canDragReorderGroups"
:animation="250"
:swap-threshold="0.75"
:invert-swap="true"
:force-fallback="true"
:fallback-on-body="true"
:fallback-tolerance="4"
filter=".instance-group-reorder-ignore, input, textarea, [contenteditable='true']"
handle=".instance-group-reorder-handle"
:prevent-on-filter="false"
ghost-class="instance-group-reorder-ghost"
chosen-class="instance-group-reorder-chosen"
drag-class="instance-group-reorder-drag"
fallback-class="instance-group-reorder-fallback"
@start="onGroupDragStart"
@end="onGroupDragEnd"
>
<template #item="{ element: instanceGroup }">
<div
:key="instanceGroup.id"
class="min-w-0 w-full"
:data-instance-group-reorder-id="instanceGroup.id"
>
<InstanceGroup
:can-drag-reorder="canDragReorderGroups"
:hide-header="
instanceGroup.id === 'group:none' && visibleInstanceGroups.length === 1
"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance(instanceGroup.id, instanceId, shiftKey)
"
/>
</div>
</template>
</Draggable>
</div>
<TransitionGroup
v-else-if="libraryGroupsLoaded"
data-library-page-background
tag="div"
class="flex flex-col"
move-class="transition-transform duration-200 ease-out"
enter-active-class="transition-[opacity,transform] duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
>
<div
v-for="instanceGroup in visibleInstanceGroups"
:key="instanceGroup.id"
class="min-w-0"
>
<InstanceGroup
:hide-header="instanceGroup.key === 'None' && visibleInstanceGroups.length === 1"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance(instanceGroup.id, instanceId, shiftKey)
"
/>
</div>
</TransitionGroup>
</Transition>
</section>
</InstanceGroupDnd>
<LibrarySelectionActionBar />
<GroupInstancesModal />
<ConfirmDeleteInstanceModal
:ref="setConfirmDeleteModal"
:instances="currentDeleteInstances"
@delete="deleteInstance"
/>
<IconEditorModal
:ref="setIconEditorModal"
:instance-id="currentIconEditorInstance?.id"
:config="currentIconEditorInstance?.icon_config"
@saved="handleInstanceIconSaved"
/>
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
<template #add_to_favorites>
<StarIcon /> {{ formatMessage(messages.addToFavorites) }}
</template>
<template #remove_from_favorites>
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" />
{{ formatMessage(messages.removeFromFavorites) }}
</template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
<template #edit_icon> <EditIcon /> {{ formatMessage(messages.editIcon) }} </template>
<template #select_icon> <UploadIcon /> {{ formatMessage(messages.selectIcon) }} </template>
<template #replace_icon> <UploadIcon /> {{ formatMessage(messages.replaceIcon) }} </template>
<template #create_icon> <PaletteIcon /> {{ formatMessage(messages.createIcon) }} </template>
<template #edit_created_icon>
<PaletteIcon /> {{ formatMessage(messages.editCreatedIcon) }}
</template>
<template #remove_icon> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
<template #duplicate>
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
</template>
<template #delete> <TrashIcon /> {{ formatMessage(messages.delete) }} </template>
<template #open> <FolderOpenIcon /> {{ formatMessage(messages.openFolder) }} </template>
<template #copy> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
<template #remove_from_group>
<MinusIcon /> {{ formatMessage(messages.removeFromGroup) }}
</template>
</ContextMenu>
</template>
<style scoped>
:global(.instance-group-reorder-ghost) {
opacity: 0.35;
}
:global(html.instance-group-reordering),
:global(html.instance-group-reordering *) {
-webkit-user-select: none !important;
cursor: grabbing !important;
user-select: none !important;
}
:global(.instance-group-reorder-fallback) {
opacity: 0.9;
pointer-events: none;
}
</style>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import DragPreview from '@/components/ui/library/instance-group/drag-preview.vue'
import {
gatherDuration,
type InstanceDragGatherItem,
} from '@/components/ui/library/instance-group/use-instance-drag-gather'
const props = defineProps<{
items: InstanceDragGatherItem[]
target: {
x: number
y: number
}
}>()
const emit = defineEmits<{
complete: []
}>()
const gathered = ref(false)
const finishing = ref(false)
let animationFrame: number | undefined
let completionTimer: number | undefined
const itemContainerStyles = computed(() =>
props.items.map((_, index) => ({
left: `${props.target.x}px`,
top: `${props.target.y}px`,
zIndex: `${index}`,
})),
)
const itemStyles = computed(() =>
props.items.map((item, index) => {
const originX = item.rect.left - props.target.x
const originY = item.rect.top - props.target.y
return {
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transform: gathered.value
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
: `translate3d(${originX}px, ${originY}px, 0)`,
transitionDuration: `${gatherDuration}ms`,
transitionDelay: `${Math.min(index, 8) * 10}ms`,
}
}),
)
onMounted(() => {
animationFrame = requestAnimationFrame(() => {
animationFrame = requestAnimationFrame(() => {
gathered.value = true
completionTimer = window.setTimeout(
() => {
finishing.value = true
emit('complete')
},
Math.max(gatherDuration - 250, 0),
)
})
})
})
onBeforeUnmount(() => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
if (completionTimer !== undefined) {
clearTimeout(completionTimer)
}
})
</script>
<template>
<Teleport to="body">
<div
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
:class="finishing ? 'opacity-0' : 'opacity-100'"
aria-hidden="true"
>
<div
v-for="(item, index) in items"
:key="item.instance.id"
class="fixed"
:style="itemContainerStyles[index]"
>
<div
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
:style="itemStyles[index]"
>
<DragPreview :instance="item.instance" />
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { TagItem } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
import { gatherDuration } from '@/components/ui/library/instance-group/use-instance-drag-gather'
import type { GameInstance } from '@/helpers/types'
const props = withDefaults(
defineProps<{
instance: GameInstance
count?: number
}>(),
{
count: 1,
},
)
const showGatheredCount = ref(false)
let countTimer: number | undefined
onMounted(() => {
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showGatheredCount.value = true
return
}
countTimer = window.setTimeout(() => {
showGatheredCount.value = true
countTimer = undefined
}, gatherDuration - 150)
})
onBeforeUnmount(() => {
if (countTimer !== undefined) {
clearTimeout(countTimer)
}
})
</script>
<template>
<div aria-hidden="true" class="relative w-full select-none">
<div
v-if="count > 1"
class="absolute inset-x-3 -bottom-2 top-2 rounded-[20px] border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
/>
<div
v-if="count > 1"
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-[20px] border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
/>
<InstanceCardView :instance="instance" class="opacity-90 shadow-lg">
<template #overlay>
<TagItem
v-if="count > 1"
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-2 font-semibold tabular-nums text-contrast motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
>
{{ count }}
</TagItem>
</template>
</InstanceCardView>
</div>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ArrowDownIcon, ArrowUpIcon, EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import { defineMessages, IconButton, useVIntl } from '@modrinth/ui'
withDefaults(
defineProps<{
deleting?: boolean
canMoveDown: boolean
canMoveUp: boolean
onAddToGroup: () => void
onDeleteGroup: () => void
onEditGroupName: () => void
onMoveDown: () => void
onMoveUp: () => void
showDelete?: boolean
showEdit?: boolean
}>(),
{
deleting: false,
showDelete: true,
showEdit: true,
},
)
const { formatMessage } = useVIntl()
const messages = defineMessages({
addToGroup: {
id: 'app.library.group.context-menu.add-to-group',
defaultMessage: 'Add to group',
},
editGroupName: {
id: 'app.library.group.edit-name',
defaultMessage: 'Edit group name',
},
deleteGroup: {
id: 'app.library.group.delete',
defaultMessage: 'Delete group',
},
moveGroupUp: {
id: 'app.library.group.move-up',
defaultMessage: 'Move group up',
},
moveGroupDown: {
id: 'app.library.group.move-down',
defaultMessage: 'Move group down',
},
})
</script>
<template>
<div
class="instance-group-reorder-ignore flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
>
<IconButton
v-tooltip="formatMessage(messages.moveGroupUp)"
:label="formatMessage(messages.moveGroupUp)"
type="quiet"
size="sm"
:disabled="!canMoveUp"
@click.stop="onMoveUp"
>
<ArrowUpIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moveGroupDown)"
:label="formatMessage(messages.moveGroupDown)"
type="quiet"
size="sm"
:disabled="!canMoveDown"
@click.stop="onMoveDown"
>
<ArrowDownIcon />
</IconButton>
<IconButton
v-if="showEdit"
v-tooltip="formatMessage(messages.editGroupName)"
:label="formatMessage(messages.editGroupName)"
type="quiet"
size="sm"
@click.stop="onEditGroupName"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.addToGroup)"
:label="formatMessage(messages.addToGroup)"
type="quiet"
size="sm"
@click.stop="onAddToGroup"
>
<SquarePlusIcon />
</IconButton>
<IconButton
v-if="showDelete"
v-tooltip="formatMessage(messages.deleteGroup)"
:label="formatMessage(messages.deleteGroup)"
type="quiet"
size="sm"
:disabled="deleting"
@click.stop="onDeleteGroup"
>
<TrashIcon />
</IconButton>
</div>
</template>
@@ -0,0 +1,560 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import {
DropdownIcon,
EditIcon,
PlusIcon,
SquarePlusIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Accordion,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
InlineEditableText,
NewModal,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
import type {
InstanceCard as InstanceCardExposed,
InstanceGroup as InstanceGroupType,
} from '@/components/ui/library/use-library'
import { useLibrary } from '@/components/ui/library/use-library'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { FAVORITES_GROUP_ID, MAX_INSTANCE_GROUP_NAME_LENGTH } from '@/helpers/instance-groups'
const INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY = 500
const props = withDefaults(
defineProps<{
canDragReorder?: boolean
hideHeader?: boolean
instanceGroup: InstanceGroupType
selectionAnchorInstanceId?: string | null
}>(),
{
canDragReorder: false,
hideHeader: false,
selectionAnchorInstanceId: null,
},
)
const { formatMessage } = useVIntl()
const appSettings = useAppSettings()
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
const { addNotification } = injectNotificationManager()
const {
isSectionCollapsed,
setSectionCollapsed,
isSearching,
deleteGroup,
renameGroup,
canMoveGroupUp,
canMoveGroupDown,
moveGroup,
groupIdPendingNameEdit,
completePendingGroupNameEdit,
handleInstanceContextMenu,
openGroupInstancesModal,
displayState,
activeInstanceGroupDrag,
instanceGroupDragTarget,
getInstanceGroupDropState,
} = useLibrary()
const showCreationModal = inject<() => void>('showCreationModal')
const instanceComponents = new Map<string, InstanceCardExposed>()
const groupDropTarget = ref<HTMLElement>()
const groupAccordion = ref<InstanceType<typeof Accordion>>()
const groupOptions = ref<InstanceType<typeof ContextMenu>>()
const groupNameInput = ref<InstanceType<typeof InlineEditableText>>()
const confirmDeleteGroupModal = ref<InstanceType<typeof NewModal>>()
const instanceGridContent = ref<HTMLElement>()
const instanceGridHeight = ref<number>()
const deletingGroup = ref(false)
const groupName = ref(props.instanceGroup.key)
const isUngrouped = computed(() => props.instanceGroup.id === 'group:none')
const isFavorites = computed(() => props.instanceGroup.id === FAVORITES_GROUP_ID)
const isCustomGroup = computed(
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
)
const isReorderableGroup = computed(
() => displayState.value.group === 'Group' && !isFavorites.value,
)
const groupContextMenuOpen = ref(false)
const isGroupToggleBlocked = computed(
() => isSearching.value || groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
)
let shouldSkipGroupToggle = false
let groupToggleEventToSkip: MouseEvent | undefined
let instanceGridResizeObserver: ResizeObserver | undefined
let instanceGridObserverActivationTimeout: ReturnType<typeof setTimeout> | undefined
const emit = defineEmits<{
(e: 'toggle-selection', instanceId: string, shiftKey: boolean): void
}>()
useDroppable({
id: computed(() => `instance-group:${props.instanceGroup.id}`),
element: groupDropTarget,
disabled: computed(() => displayState.value.group !== 'Group'),
data: computed(() => ({
groupId: props.instanceGroup.id,
})),
})
const messages = defineMessages({
ungrouped: {
id: 'app.library.group.ungrouped',
defaultMessage: 'Ungrouped',
},
favorites: {
id: 'app.library.group.favorites',
defaultMessage: 'Favorites',
},
collapseGroup: { id: 'app.library.group.collapse', defaultMessage: 'Collapse group' },
expandGroup: { id: 'app.library.group.expand', defaultMessage: 'Expand group' },
deleteGroup: {
id: 'app.library.group.delete',
defaultMessage: 'Delete group',
},
editGroupName: {
id: 'app.library.group.edit-name',
defaultMessage: 'Edit group name',
},
renameGroupFailed: {
id: 'app.library.group.rename-failed',
defaultMessage: 'Unable to rename group',
},
groupNameEmpty: {
id: 'app.library.group.name-empty',
defaultMessage: 'Group names cannot be empty.',
},
groupNameTooLong: {
id: 'app.library.group.name-too-long',
defaultMessage: 'Group names cannot be longer than {maxLength} characters.',
},
groupNameReserved: {
id: 'app.library.group.name-reserved',
defaultMessage: '"None" is reserved and cannot be used as a group name.',
},
deleteGroupDescription: {
id: 'app.library.group.delete-description',
defaultMessage: 'Instances in this group will be ungrouped.',
},
newInstance: {
id: 'app.library.context-menu.create-instance',
defaultMessage: 'New instance',
},
addToGroup: {
id: 'app.library.group.context-menu.add-to-group',
defaultMessage: 'Add to group',
},
emptyGroup: {
id: 'app.library.group.empty',
defaultMessage: 'Drag and drop to add instances.',
},
})
function openInstanceContextMenu(event: MouseEvent, instanceId: string, instanceGroupId: string) {
const instanceComponent = instanceComponents.get(instanceId)
if (!instanceComponent) return
handleInstanceContextMenu(event, instanceComponent, instanceGroupId)
}
function setInstanceComponent(instanceId: string, component: unknown) {
if (component) {
instanceComponents.set(instanceId, component as InstanceCardExposed)
} else {
instanceComponents.delete(instanceId)
}
}
async function removeGroup() {
if (deletingGroup.value) return
deletingGroup.value = true
const deleted = await deleteGroup(props.instanceGroup.id)
deletingGroup.value = false
if (deleted) {
confirmDeleteGroupModal.value?.hide()
}
}
function requestGroupDeletion() {
if (isUngrouped.value || isFavorites.value) return
if (props.instanceGroup.instances.length > 0) {
confirmDeleteGroupModal.value?.show()
} else {
void removeGroup()
}
}
function openGroupContextMenu(event: MouseEvent) {
groupContextMenuOpen.value = true
groupOptions.value?.showMenu(
event,
props.instanceGroup,
isFavorites.value
? [{ name: 'new_instance' }]
: isCustomGroup.value
? [
{ name: 'new_instance' },
{ name: 'add_instances' },
{ name: 'edit_name' },
{ type: 'divider' },
{ name: 'delete_group', color: 'danger' },
]
: [{ name: 'new_instance' }],
)
}
function handleGroupOption({ option }: { option: string }) {
if (option === 'new_instance') {
showCreationModal?.()
return
}
if (option === 'add_instances') {
openGroupInstancesModal(props.instanceGroup.id)
return
}
if (option === 'edit_name') {
void groupNameInput.value?.startEditing()
return
}
if (option === 'delete_group') {
requestGroupDeletion()
}
}
function prepareGroupToggle(event: PointerEvent) {
const editor = groupNameInput.value
const contextMenuWasOpen = groupContextMenuOpen.value
if (contextMenuWasOpen) {
groupOptions.value?.hideMenu()
}
shouldSkipGroupToggle = Boolean(
contextMenuWasOpen ||
(editor?.isEditing && event.target instanceof Node && !editor.$el.contains(event.target)),
)
}
function captureGroupClick(event: MouseEvent) {
groupToggleEventToSkip = shouldSkipGroupToggle ? event : undefined
shouldSkipGroupToggle = false
}
function toggleGroup(event: MouseEvent) {
if (event === groupToggleEventToSkip) {
groupToggleEventToSkip = undefined
return
}
if (isSearching.value) return
if (groupAccordion.value?.isOpen) {
groupAccordion.value.close()
} else {
groupAccordion.value?.open()
}
}
function validateGroupName(value: string) {
const normalizedGroupName = value.trim()
let reason: string | undefined
if (normalizedGroupName.length === 0) {
reason = formatMessage(messages.groupNameEmpty)
} else if (normalizedGroupName.length > MAX_INSTANCE_GROUP_NAME_LENGTH) {
reason = formatMessage(messages.groupNameTooLong, {
maxLength: MAX_INSTANCE_GROUP_NAME_LENGTH,
})
} else if (normalizedGroupName.toLowerCase() === 'none') {
reason = formatMessage(messages.groupNameReserved)
}
if (reason) {
addNotification({
type: 'error',
title: formatMessage(messages.renameGroupFailed),
text: reason,
})
return false
}
return true
}
async function updateGroupName(value: string) {
return await renameGroup(props.instanceGroup.id, value)
}
watch(
() => props.instanceGroup.key,
(value) => {
groupName.value = value
},
)
watch(
[() => props.instanceGroup.id, groupIdPendingNameEdit],
async ([groupId, pendingGroupId]) => {
if (groupId !== pendingGroupId) return
await nextTick()
groupDropTarget.value?.scrollIntoView({ block: 'nearest' })
await groupNameInput.value?.startEditing()
completePendingGroupNameEdit(groupId)
},
{ immediate: true, flush: 'post' },
)
watch(
() => props.hideHeader,
(hideHeader) => {
if (hideHeader) {
groupAccordion.value?.open()
}
},
{ flush: 'post' },
)
function stopInstanceGridResizeObserver() {
clearTimeout(instanceGridObserverActivationTimeout)
instanceGridResizeObserver?.disconnect()
}
function startInstanceGridResizeObserver() {
stopInstanceGridResizeObserver()
instanceGridHeight.value = undefined
instanceGridObserverActivationTimeout = setTimeout(() => {
instanceGridObserverActivationTimeout = undefined
const gridContent = instanceGridContent.value
if (!gridContent?.isConnected) return
instanceGridHeight.value = gridContent.getBoundingClientRect().height
instanceGridResizeObserver = new ResizeObserver(() => {
if (!gridContent.isConnected) return
instanceGridHeight.value = gridContent.getBoundingClientRect().height
})
instanceGridResizeObserver.observe(gridContent)
}, INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY)
}
onActivated(startInstanceGridResizeObserver)
onDeactivated(stopInstanceGridResizeObserver)
onMounted(startInstanceGridResizeObserver)
</script>
<template>
<div
ref="groupDropTarget"
class="instance-group group/instance-container relative select-none pb-3"
@contextmenu.prevent.stop="openGroupContextMenu"
>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="!opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="!opacity-0"
>
<div
v-if="
activeInstanceGroupDrag &&
instanceGroupDragTarget === instanceGroup.id &&
getInstanceGroupDropState(instanceGroup.id).canDrop
"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 opacity-40 border-dashed border-contrast bg-transparent"
/>
</Transition>
<div
v-if="!hideHeader"
class="group/header h-10 flex w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
:class="{
'instance-group-reorder-handle': isReorderableGroup && canDragReorder,
}"
>
<div
class="group/open-target flex min-w-0 items-center gap-2"
:class="isGroupToggleBlocked ? 'cursor-default' : 'cursor-pointer'"
@click="toggleGroup"
@click.capture="captureGroupClick"
@pointerdown.capture="prepareGroupToggle"
>
<button
class="flex shrink-0 items-center border-0 bg-transparent p-0"
:class="isGroupToggleBlocked ? 'cursor-default' : 'cursor-pointer'"
type="button"
:aria-expanded="groupAccordion?.isOpen"
:aria-label="
formatMessage(groupAccordion?.isOpen ? messages.collapseGroup : messages.expandGroup)
"
@click.stop="toggleGroup"
>
<DropdownIcon
class="size-5 shrink-0 text-secondary transition-all duration-300 group-hover/open-target:text-primary"
:class="{ 'rotate-180': groupAccordion?.isOpen }"
/>
</button>
<InlineEditableText
v-if="!isUngrouped && !isFavorites"
ref="groupNameInput"
v-model="groupName"
activation-mode="manual"
class="text-base font-semibold !h-10 text-primary select-none group-hover/open-target:text-contrast"
:edit-label="formatMessage(commonMessages.renameButton)"
max-width="24rem"
icon-text-class="select-none"
:max-length="MAX_INSTANCE_GROUP_NAME_LENGTH"
:on-change="updateGroupName"
:validate="validateGroupName"
/>
<span
v-else-if="isUngrouped"
class="text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
>
{{ formatMessage(messages.ungrouped) }}
</span>
<span
v-else
class="flex items-center gap-1.5 text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
>
{{ formatMessage(messages.favorites) }}
</span>
<TagItem
v-if="instanceGroup.instances.length"
class="shrink-0 border-surface-3 bg-surface-2"
>
{{ instanceGroup.instances.length }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<GroupActionButtons
v-if="isCustomGroup || isUngrouped"
:can-move-down="canMoveGroupDown(instanceGroup.id)"
:can-move-up="canMoveGroupUp(instanceGroup.id)"
:deleting="deletingGroup"
:on-add-to-group="() => openGroupInstancesModal(instanceGroup.id)"
:on-delete-group="requestGroupDeletion"
:on-edit-group-name="() => groupNameInput?.startEditing()"
:on-move-down="() => moveGroup(instanceGroup.id, 1)"
:on-move-up="() => moveGroup(instanceGroup.id, -1)"
:show-delete="!isUngrouped"
:show-edit="!isUngrouped"
/>
</div>
<Accordion
ref="groupAccordion"
:open-by-default="hideHeader || !isSectionCollapsed(instanceGroup.id)"
:force-open="isSearching"
class="w-full"
@on-open="setSectionCollapsed(instanceGroup.id, false)"
@on-close="setSectionCollapsed(instanceGroup.id, true)"
>
<div
class="mt-2.5 overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none"
:style="{
height: instanceGridHeight === undefined ? undefined : `${instanceGridHeight}px`,
}"
>
<div ref="instanceGridContent">
<TransitionGroup
tag="section"
class="grid min-h-[45px] w-full gap-3 overflow-y-auto scroll-smooth"
:class="
compactMode
? 'grid-cols-[repeat(auto-fill,minmax(min(15rem,100%),1fr))]'
: 'grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))] max-xl:grid-cols-[repeat(auto-fill,minmax(min(8rem,100%),1fr))]'
"
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
enter-active-class="transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none"
enter-from-class="opacity-0"
enter-to-class="opacity-100 scale-100"
>
<div
v-for="instance in instanceGroup.instances"
:key="instance.id"
class="min-w-0 w-full"
>
<InstanceCard
:ref="(component: unknown) => setInstanceComponent(instance.id, component)"
:instance="instance"
:instance-group-id="instanceGroup.id"
:is-selection-anchor="selectionAnchorInstanceId === instance.id"
@toggle-selection="
(shiftKey: boolean) => emit('toggle-selection', instance.id, shiftKey)
"
@contextmenu.prevent.stop="
(event: MouseEvent) =>
openInstanceContextMenu(event, instance.id, instanceGroup.id)
"
/>
</div>
<p
v-if="instanceGroup.instances.length === 0"
key="empty-group"
class="col-span-full m-0 pt-1 pl-0.5 text-base font-base text-secondary opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</TransitionGroup>
</div>
</div>
</Accordion>
</div>
<ContextMenu
ref="groupOptions"
@menu-closed="groupContextMenuOpen = false"
@option-clicked="handleGroupOption"
>
<template #new_instance> <PlusIcon /> {{ formatMessage(messages.newInstance) }} </template>
<template #add_instances>
<SquarePlusIcon /> {{ formatMessage(messages.addToGroup) }}
</template>
<template #edit_name> <EditIcon /> {{ formatMessage(messages.editGroupName) }} </template>
<template #delete_group> <TrashIcon /> {{ formatMessage(messages.deleteGroup) }} </template>
</ContextMenu>
<NewModal
ref="confirmDeleteGroupModal"
:header="formatMessage(messages.deleteGroup)"
fade="danger"
width="500px"
>
<p class="m-0 text-base text-primary">
{{ formatMessage(messages.deleteGroupDescription) }}
</p>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="confirmDeleteGroupModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="red" :disabled="deletingGroup" @click="removeGroup">
<TrashIcon />
{{ formatMessage(messages.deleteGroup) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { Avatar, truncatedTooltip } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { getInstanceIconUrl } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
const props = withDefaults(
defineProps<{
instance: GameInstance
selected?: boolean
}>(),
{
selected: false,
},
)
const iconSrc = computed(() => getInstanceIconUrl(props.instance.icon_path))
const appSettings = useAppSettings()
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
const nameRef = ref<HTMLElement | null>(null)
const versionRef = ref<HTMLElement | null>(null)
</script>
<template>
<div
class="relative flex w-full min-w-0 select-none overflow-clip border border-solid bg-surface-3 text-left transition-all"
:class="{
'flex-row items-center justify-start gap-2.5 rounded-xl p-2.5': compactMode,
'flex-col items-start justify-end gap-3 rounded-[20px] p-3': !compactMode,
'[border-color:color-mix(in_srgb,var(--color-text-primary)_40%,transparent)] brightness-110':
selected,
'border-surface-4': !selected,
}"
>
<div
class="relative flex shrink-0 items-center overflow-clip"
:class="compactMode ? 'size-10 rounded-lg' : 'aspect-square min-w-full rounded-2xl'"
>
<Avatar
class="pointer-events-none outline-none"
:class="compactMode ? '!rounded-lg' : '!rounded-2xl'"
size="100%"
:src="iconSrc"
:tint-by="instance.id"
alt=""
no-shadow
/>
<slot name="loading" :compact="compactMode" />
<div
class="absolute z-[1] flex items-center justify-center"
:class="compactMode ? 'inset-0' : 'bottom-1.5 right-1.5 size-12'"
>
<slot name="leading" :compact="compactMode" />
</div>
</div>
<div
class="flex min-w-0 w-full flex-col items-start justify-center gap-1 px-0.5"
:class="{ 'pr-10': compactMode }"
>
<p
ref="nameRef"
v-tooltip="truncatedTooltip(nameRef, instance.name)"
class="m-0 w-full truncate text-base font-semibold leading-5 text-contrast"
>
{{ instance.name }}
</p>
<p
ref="versionRef"
v-tooltip="truncatedTooltip(versionRef, `${instance.loader} ${instance.game_version}`)"
class="m-0 w-full truncate text-sm font-medium capitalize leading-[18px] text-primary"
>
{{ instance.loader }} {{ instance.game_version }}
</p>
</div>
<slot name="overlay" :compact="compactMode" />
</div>
</template>
@@ -0,0 +1,386 @@
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, DownloadIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import { defineMessages, IconButton, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useEventListener, useMagicKeys } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import { useAppEvent } from '@/composables/use-app-event'
import { handleSevereError } from '@/composables/use-error.js'
import { trackEvent } from '@/helpers/analytics'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils.js'
type ProcessEvent = 'installing' | 'launched' | 'finished'
const LOADING_INDICATOR_DELAY_MS = 100
const instanceCardSensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
selectInstance: {
id: 'app.library.instance.select-with-name',
defaultMessage: 'Select {name}',
},
deselectInstance: {
id: 'app.library.instance.deselect-with-name',
defaultMessage: 'Deselect {name}',
},
openInstance: {
id: 'app.library.instance.open-with-name',
defaultMessage: 'Open {name}',
},
loading: { id: 'app.library.instance.loading', defaultMessage: 'Instance is loading...' },
installing: { id: 'app.library.instance.installing', defaultMessage: 'Installing...' },
stop: { id: 'app.library.instance.stop', defaultMessage: 'Stop' },
repair: { id: 'app.library.instance.repair', defaultMessage: 'Repair' },
play: { id: 'app.library.instance.play', defaultMessage: 'Play' },
select: { id: 'app.library.instance.select', defaultMessage: 'Select instance' },
deselect: { id: 'app.library.instance.deselect', defaultMessage: 'Deselect instance' },
})
const {
displayState,
selectedLibraryInstances,
isLibraryInstanceSelectionActive,
activeDraggedInstanceKeys,
} = useLibrary()
const props = defineProps<{
instance: GameInstance
instanceGroupId: string
isSelectionAnchor?: boolean
}>()
const emit = defineEmits<{
(e: 'toggle-selection', shiftKey: boolean): void
}>()
const instanceCardElement = ref<InstanceType<typeof InstanceCardView> | null>(null)
const playing = ref(false)
const loading = ref(false)
const currentEvent = ref<ProcessEvent | null>(null)
const isPrimaryPointerDown = ref(false)
const modLoading = computed(
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !playing.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
const loadingIndicatorRequested = computed(
() => !playing.value && (modLoading.value || installing.value),
)
const loadingIndicatorVisible = ref(false)
watch(
loadingIndicatorRequested,
(requested, _, onCleanup) => {
loadingIndicatorVisible.value = false
if (!requested) return
const timeout = setTimeout(() => {
loadingIndicatorVisible.value = true
}, LOADING_INDICATOR_DELAY_MS)
onCleanup(() => clearTimeout(timeout))
},
{ immediate: true },
)
const selectionKey = computed(() =>
getLibraryInstanceSelectionKey({
instanceId: props.instance.id,
groupId: props.instanceGroupId,
}),
)
const selected = computed(() => selectedLibraryInstances.value.has(selectionKey.value))
const keys = useMagicKeys()
const holdingShift = computed(() => keys.shift.value)
const isPartOfActiveDrag = computed(() => activeDraggedInstanceKeys.value.has(selectionKey.value))
const { isDragging } = useDraggable({
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
element: instanceCardElement,
disabled: computed(() => displayState.value.group !== 'Group'),
sensors: instanceCardSensors,
data: computed(() => ({
instanceId: props.instance.id,
fromGroup: props.instanceGroupId,
})),
})
const router = useRouter()
const seeInstance = async () => {
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
}
const toggleSelection = (event?: MouseEvent) => {
emit('toggle-selection', event?.shiftKey ?? false)
}
const handlePointerDown = (event: PointerEvent) => {
isPrimaryPointerDown.value =
event.button === 0 &&
!(event.target instanceof Element && event.target.closest('.selection-button'))
}
const resetPrimaryPointer = () => {
isPrimaryPointerDown.value = false
}
useEventListener(window, 'pointerup', resetPrimaryPointer)
useEventListener(window, 'pointercancel', resetPrimaryPointer)
useEventListener(window, 'blur', resetPrimaryPointer)
const activateCard = (event: MouseEvent) => {
if (isLibraryInstanceSelectionActive.value || event.shiftKey) {
toggleSelection(event)
} else {
void seeInstance()
}
}
const handleCardKeydown = (event: KeyboardEvent) => {
if (event.target !== event.currentTarget) return
if (event.key === 'Enter') {
event.preventDefault()
if (isLibraryInstanceSelectionActive.value) {
toggleSelection()
} else {
void seeInstance()
}
} else if (event.key === ' ' && isLibraryInstanceSelectionActive.value) {
event.preventDefault()
toggleSelection()
}
}
const checkProcess = async () => {
const runningProcesses = (await get_by_instance_id(props.instance.id).catch(handleError)) ?? []
playing.value = runningProcesses.length > 0
}
const play = async (event: MouseEvent | null, context: string) => {
event?.stopPropagation()
if (props.instance.quarantined) return
loading.value = true
await run(props.instance.id)
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
.finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
})
loading.value = false
}
const stop = async (event: MouseEvent | null, context: string) => {
event?.stopPropagation()
playing.value = false
await kill(props.instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
}
const repair = async (event: MouseEvent) => {
event.stopPropagation()
if (props.instance.quarantined) return
if (
props.instance.install_stage !== 'pack_installed' &&
(props.instance.link?.type === 'modrinth_modpack' ||
props.instance.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(props.instance.id, {
type: 'fromVersionId',
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
title: props.instance.name,
}).catch(handleError)
} else {
await install_existing_instance(props.instance.id, false).catch(handleError)
}
}
const openFolder = async () => {
await showInstanceInFolder(props.instance.id)
}
const addContent = async () => {
if (props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.id },
})
}
defineExpose({
play,
stop,
seeInstance,
openFolder,
addContent,
instance: props.instance,
})
useAppEvent('process', (event) => {
if (event.instance_id === props.instance.id) {
currentEvent.value = event.event
playing.value = event.event === 'launched'
}
})
onMounted(() => {
void checkProcess()
})
</script>
<template>
<InstanceCardView
ref="instanceCardElement"
class="group/card cursor-pointer -outline-offset-2 focus-visible:!outline-2 hover:brightness-110"
:class="{
'!scale-100': isDragging,
'opacity-50': isPartOfActiveDrag,
'scale-[0.95]': isPrimaryPointerDown && !isLibraryInstanceSelectionActive,
}"
:instance="instance"
:selected="selected"
data-library-instance-card
:data-instance-id="instance.id"
:data-instance-group="instanceGroupId"
role="button"
tabindex="0"
:aria-label="
isLibraryInstanceSelectionActive
? formatMessage(selected ? messages.deselectInstance : messages.selectInstance, {
name: instance.name,
})
: formatMessage(messages.openInstance, { name: instance.name })
"
:aria-pressed="isLibraryInstanceSelectionActive ? selected : undefined"
@click="activateCard"
@keydown="handleCardKeydown"
@mouseenter="checkProcess"
@pointerdown="handlePointerDown"
>
<template #loading="{ compact }">
<div
v-if="loadingIndicatorVisible"
class="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center"
>
<div class="absolute inset-0 bg-surface-1 opacity-30" />
<SpinnerIcon
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
class="relative animate-spin text-contrast"
:class="compact ? 'size-5' : 'size-[30%]'"
tabindex="-1"
/>
</div>
</template>
<template #leading="{ compact }">
<div
class="relative flex shrink-0 items-center justify-center"
:class="compact ? 'size-10' : 'size-12'"
>
<div class="absolute inset-0 flex items-center justify-center">
<IconButton
v-if="playing"
v-tooltip="formatMessage(messages.stop)"
:label="formatMessage(messages.stop)"
type="colored"
color="red"
:size="compact ? 'md' : 'lg'"
@click="(e) => stop(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<StopCircleIcon />
</IconButton>
<IconButton
v-else-if="
!modLoading &&
!installing &&
!isLibraryInstanceSelectionActive &&
!installed &&
!instance.quarantined
"
v-tooltip="formatMessage(messages.repair)"
:label="formatMessage(messages.repair)"
type="colored"
color="brand"
:size="compact ? 'md' : 'lg'"
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
@click="(e) => repair(e)"
>
<DownloadIcon />
</IconButton>
<IconButton
v-else-if="
!modLoading &&
!installing &&
!isLibraryInstanceSelectionActive &&
!instance.quarantined
"
v-tooltip="formatMessage(messages.play)"
:label="formatMessage(messages.play)"
type="colored"
color="brand"
:size="compact ? 'md' : 'lg'"
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<PlayIcon class="translate-x-px" />
</IconButton>
</div>
</div>
</template>
<template #overlay="{ compact }">
<button
type="button"
class="selection-button group/selection absolute z-[2] flex size-[50px] cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
:class="compact ? '-right-1 -top-1' : 'right-2 top-1.5'"
:aria-label="formatMessage(selected ? messages.deselect : messages.select)"
:aria-pressed="selected"
@click.stop="toggleSelection"
>
<span
v-tooltip="formatMessage(selected ? messages.deselect : messages.select)"
class="relative flex items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover/card:opacity-100 group-hover/selection:brightness-125"
:class="{
'size-[20px]': compact,
'size-[24px]': !compact,
'border-0 !opacity-100': selected,
'border-2 border-solid border-primary bg-transparent': !selected,
'[outline:3px_solid_var(--color-purple)] outline-offset-1':
holdingShift && isSelectionAnchor,
}"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-contrast" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
</template>
</InstanceCardView>
</template>
@@ -0,0 +1,236 @@
<script setup lang="ts">
import {
DragDropProvider,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
DragOverlay,
type DragStartEvent,
} from '@dnd-kit/vue'
import { computed, nextTick, onBeforeUnmount, ref, toRef, watch } from 'vue'
import DragGather from '@/components/ui/library/instance-group/drag-gather.vue'
import DragPreview from '@/components/ui/library/instance-group/drag-preview.vue'
import { useInstanceDragGather } from '@/components/ui/library/instance-group/use-instance-drag-gather'
import { useLibrary } from '@/components/ui/library/use-library'
import type { GameInstance } from '@/helpers/types'
type InstanceDragData = {
instanceId: string
fromGroup: string
}
type InstanceGroupDndDropData = {
groupId: string
}
const props = defineProps<{
instances: GameInstance[]
}>()
const GROUP_HOVER_OPEN_DELAY = 750
const {
activeInstanceGroupDrag,
instanceGroupDragPointer,
instanceGroupDragStatus,
instanceGroupDragTarget,
isSectionCollapsed,
setSectionCollapsed,
startInstanceGroupDrag,
updateInstanceGroupDrag,
finishInstanceGroupDrag,
setInstanceGroupDragTarget,
getInstanceGroupDropState,
moveDraggedInstancesToGroup,
} = useLibrary()
const draggedInstances = computed(() => {
const drag = activeInstanceGroupDrag.value
if (!drag) return []
const draggedInstanceIds = new Set(drag.instances.map((selection) => selection.instanceId))
return props.instances.filter((instance) => draggedInstanceIds.has(instance.id))
})
const draggedInstance = computed(() => {
const drag = activeInstanceGroupDrag.value
return drag
? props.instances.find((instance) => instance.id === drag.primaryInstanceId)
: undefined
})
const {
items: gatherItems,
target: gatherTarget,
isGathering,
start: startGather,
updateTarget: updateGatherTarget,
clear: clearGather,
finish: finishGather,
} = useInstanceDragGather(toRef(props, 'instances'))
const instanceGroupDragStatusPopover = ref<HTMLElement>()
let statusPopoverFrame: number | undefined
let groupHoverOpenTimeout: ReturnType<typeof setTimeout> | undefined
const instanceGroupDragStatusStyle = computed(() => ({
left: `${Math.min(instanceGroupDragPointer.value.x + 4, window.innerWidth - 220)}px`,
top: `${Math.min(instanceGroupDragPointer.value.y + 4, window.innerHeight - 48)}px`,
}))
const hoveredCollapsedGroupId = computed(() => {
const target = instanceGroupDragTarget.value
if (
!activeInstanceGroupDrag.value ||
!target ||
!isSectionCollapsed(target) ||
!getInstanceGroupDropState(target).canDrop
) {
return null
}
return target
})
watch(
activeInstanceGroupDrag,
(drag) => {
if (statusPopoverFrame !== undefined) {
cancelAnimationFrame(statusPopoverFrame)
statusPopoverFrame = undefined
}
const popover = instanceGroupDragStatusPopover.value
if (!drag) {
if (popover?.matches(':popover-open')) {
popover.hidePopover()
}
return
}
statusPopoverFrame = requestAnimationFrame(() => {
statusPopoverFrame = undefined
const currentPopover = instanceGroupDragStatusPopover.value
if (activeInstanceGroupDrag.value && !currentPopover?.matches(':popover-open')) {
currentPopover?.showPopover()
}
})
},
{ flush: 'sync' },
)
function clearGroupHoverOpenTimeout() {
if (groupHoverOpenTimeout !== undefined) {
clearTimeout(groupHoverOpenTimeout)
}
groupHoverOpenTimeout = undefined
}
watch(hoveredCollapsedGroupId, (target) => {
clearGroupHoverOpenTimeout()
if (!target) return
groupHoverOpenTimeout = setTimeout(() => {
groupHoverOpenTimeout = undefined
if (hoveredCollapsedGroupId.value === target) {
setSectionCollapsed(target, false)
}
}, GROUP_HOVER_OPEN_DELAY)
})
onBeforeUnmount(() => {
if (statusPopoverFrame !== undefined) {
cancelAnimationFrame(statusPopoverFrame)
}
clearGroupHoverOpenTimeout()
})
function isAltKeyPressed(event?: Event) {
return event instanceof MouseEvent || event instanceof KeyboardEvent ? event.altKey : false
}
function handleDragStart(event: DragStartEvent) {
const sourceData = event.operation.source?.data as InstanceDragData | undefined
if (!sourceData) return
const pointer = event.operation.position.current
startInstanceGroupDrag(
sourceData.instanceId,
sourceData.fromGroup,
pointer,
isAltKeyPressed(event.nativeEvent),
)
startGather(activeInstanceGroupDrag.value, sourceData, pointer)
}
function handleDragMove(event: DragMoveEvent) {
const pointer = event.to ?? event.operation.position.current
updateInstanceGroupDrag(pointer, isAltKeyPressed(event.nativeEvent))
if (gatherItems.value.length > 0) {
updateGatherTarget(pointer)
}
}
function handleDragOver(event: DragOverEvent) {
const targetData = event.operation.target?.data as InstanceGroupDndDropData | undefined
setInstanceGroupDragTarget(targetData?.groupId ?? null)
}
async function handleDragEnd(event: DragEndEvent) {
clearGroupHoverOpenTimeout()
const targetData = event.operation.target?.data as InstanceGroupDndDropData | undefined
let isMovingInstances = false
if (!event.canceled && targetData) {
const dropState = getInstanceGroupDropState(targetData.groupId)
if (dropState.canDrop) {
void moveDraggedInstancesToGroup(targetData.groupId, dropState.operation === 'add')
isMovingInstances = true
}
}
if (isMovingInstances) {
await nextTick()
}
clearGather()
finishInstanceGroupDrag()
}
</script>
<template>
<DragDropProvider
@drag-start="handleDragStart"
@drag-move="handleDragMove"
@drag-over="handleDragOver"
@drag-end="handleDragEnd"
>
<slot />
<Teleport to="body">
<div class="pointer-events-none fixed inset-0 z-[9999]">
<DragOverlay :drop-animation="null">
<div
v-if="draggedInstance"
class="w-full transition-all duration-150 ease-out"
:class="isGathering ? 'scale-[0.975]' : 'scale-100'"
>
<DragPreview :instance="draggedInstance" :count="draggedInstances.length" />
</div>
</DragOverlay>
</div>
</Teleport>
</DragDropProvider>
<DragGather
v-if="gatherItems.length > 0"
:items="gatherItems"
:target="gatherTarget"
@complete="finishGather"
/>
<Teleport to="body">
<div
ref="instanceGroupDragStatusPopover"
popover="manual"
class="pointer-events-none fixed inset-auto z-[9999] m-0 rounded-xl border border-solid border-surface-5 bg-surface-4 px-2 py-1.5 text-sm font-semibold text-contrast empty:hidden"
:style="instanceGroupDragStatusStyle"
>
{{ instanceGroupDragStatus }}
</div>
</Teleport>
</template>
@@ -0,0 +1,130 @@
import { onBeforeUnmount, type Ref, ref } from 'vue'
import type { ActiveInstanceGroupDrag } from '@/components/ui/library/use-library'
import type { GameInstance } from '@/helpers/types'
type Point = {
x: number
y: number
}
type InstanceDragData = {
instanceId: string
fromGroup: string
}
export const gatherDuration = 500
export type InstanceDragGatherItem = {
instance: GameInstance
rect: {
left: number
top: number
width: number
height: number
}
}
export function useInstanceDragGather(instances: Ref<GameInstance[]>) {
const items = ref<InstanceDragGatherItem[]>([])
const target = ref<Point>({ x: 0, y: 0 })
const targetOffset = ref<Point>({ x: 0, y: 0 })
const isGathering = ref(false)
let cleanupTimer: number | undefined
const clearCleanupTimer = () => {
if (cleanupTimer !== undefined) {
clearTimeout(cleanupTimer)
cleanupTimer = undefined
}
}
const clear = () => {
clearCleanupTimer()
items.value = []
targetOffset.value = { x: 0, y: 0 }
isGathering.value = false
}
const updateTarget = (pointer: Point) => {
target.value = {
x: pointer.x + targetOffset.value.x,
y: pointer.y + targetOffset.value.y,
}
}
const start = (
drag: ActiveInstanceGroupDrag | null,
source: InstanceDragData,
pointer: Point,
) => {
clear()
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!drag || drag.instances.length < 2 || reduceMotion) return
const instanceCards = Array.from(
document.querySelectorAll<HTMLElement>('[data-library-instance-card]'),
)
const sourceCard = instanceCards.find(
(card) =>
card.dataset.instanceId === source.instanceId &&
card.dataset.instanceGroup === source.fromGroup,
)
if (sourceCard) {
const sourceRect = sourceCard.getBoundingClientRect()
targetOffset.value = {
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
}
}
items.value = drag.instances.flatMap(({ instanceId, groupId }) => {
const instance = instances.value.find((candidate) => candidate.id === instanceId)
const matchingCards = instanceCards.filter(
(card) => card.dataset.instanceId === instanceId && card.getClientRects().length > 0,
)
const card =
matchingCards.find((candidate) => candidate.dataset.instanceGroup === groupId) ??
matchingCards[0]
if (!instance || !card) return []
const rect = card.getBoundingClientRect()
return [
{
instance,
rect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
},
]
})
updateTarget(pointer)
isGathering.value = items.value.length > 0
}
const finish = () => {
isGathering.value = false
clearCleanupTimer()
cleanupTimer = window.setTimeout(() => {
items.value = []
cleanupTimer = undefined
}, 400)
}
onBeforeUnmount(clearCleanupTimer)
return {
items,
target,
isGathering,
start,
updateTarget,
clear,
finish,
}
}

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