Compare commits

...
Author SHA1 Message Date
Prospector a9de420d57 update date + changelog 2026-08-13 11:09:05 -07:00
Prospector ab7359a4c5 minor fixes to disclosures, basic markdown support (#7133) 2026-08-13 10:58:47 -07:00
Truman GaoandCalum H. 97b5138672 fix: modpack export (#7119)
* fix: stale function call set_webview_visible

* fix: modpack export progress bar and add notification when done

* remove: force_no_zip64

* feat: switch to zip crate

* add typo exception

* fix: remove unit test (dont do)

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-08-13 16:37:39 +00:00
Calum H. 579109a478 refactor: app event bus (#6985)
* refactor: app event bus

* fix: use messagepack + cleanup

* fix: use postcard

* fix: lint

* fix: import gate

* fix: prettierignore + lint

* fix: lint

* fix: rev
2026-08-13 16:33:06 +00:00
Prospector 5f7493491f changelog 2026-08-13 09:01:59 -07:00
755825b09a project disclosures frontend (#6955)
* begin project disclosures

* new project settings header

* begin disclosure settings, edit content rules

* togglecards

* update phrasing of the rules, prepr

* more disclosure settings structuring

* add functionality toggle

* implement functionality with staging api

* improve project settings head titles

* feat(labrinth): project disclosures model

* feat(labrinth): project disclosures database model

* feat(labrinth): project disclosures get endpoint

* feat(labrinth): censor user ids if set by moderator

* feat(labrinth): wrap disclosures in struct

* feat(labrinth): edit project disclosures endpoint

* style(labrinth): cargo fmt

* style(labrinth): fix typo

* feat(labrinth): add fields for ai content disclosure

* fix(labrinth): field typo

* chore(labrinth): update query cache

* feat(labrinth): index disclosures in search

* refactor(labrinth): use enum instead of bools

* feat(labrinth): trigger incremental index on disclosure change

* feat(labrinth): change archived status to disclosure

* feat(labrinth): use disclosure for archival status

* fix(labrinth): error type for deserialization

* fix(labrinth): migration timestamp

* fix(labrinth): add disclosures to elasticsearch schema

* disclosures in search

* update photosensitivity warning copy

* update archiving, move general project settings to v3 (start redesign there), use project id lookups for stable cache keys, advanced in server search

* blue archive banner

* display AI use types

* improve labels

* add modrinth to link

* add placeholder missing disclosure report type

* add AI metadata checking to block image uploads with notice

* update copy

* update section 4 of rules

* Update rule 6 layout

* fix(labrinth): don't remove archival disclosure when changing status via v3 api

* feat(labrinth): derive str for ai usages and telemtry consent

* feat(labrinth): include ai usages and telemetry consent in disclosure types

* Update moderation checklist (#7056)

* fix: missing delphi severity (#7054)

* Utils nav, new disclosures stage, ai button in rules

* add prefix to collect

* Finish up new disclosures stage

* update r4 messages

* new r4 msg, update showcase clarity message

* fix: version upload failing to detect mrpack loader (#7063)

* fix: mrpack exporting with zip64 (#7064)

* fix: action bar max width (#7048)

* fix: action bar max width

* fix: width

* changelog

* Rule placeholders with subsections & anchors, update messages.

* update new messages to account for new rule and placeholder layout

* Add nag for content disclosures

---------

Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>

* prepr

* refactor(labrinth): move disclosure parsing to document creation

* fix(labrinth): prevent removing moderator added archival disclosures

* fix(labrinth): dedupe ai usages

* fix(labrinth): auth logic on archived disclosure removal

* feat(labrinth): add interactions field

* style(labrinth): cargo fmt

* feat(labrinth): granular disclosure locking

* add 5.8, move 5.9, update messages and placeholders accordingly.

* move disclosures stage earlier

* include consent model info in telemetry disclosure msgs

* prepr + update date

* typo

* qa pass

* search suboptions

* add empty state

* checkboxes in column

* persistent advanced filters

* blog draft

* prepr

* support soft deletion

* reload instead of optimistically updating

* update blog

* TOS update + minor verbiage adjustment

* 45 day grace

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: ThatGravyBoat <gravy@thatgravyboat.tech>
Co-authored-by: chyzman <chyzalt@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
2026-08-13 09:01:18 -07:00
Calum H. 06b7c44dcc refactor: content tab styling (#7091)
* refactor: managed content card

* fix: update filter pill styling to be chips-like

* fix: modpack exporting

* fixed: vanilla server projs + header

* refactor: filters

* refactor: detect external file icons + other metadata inside

* refactor: support json5 metadata

* fix: qa

* fix: qa

* fix: fmt + prepr

* fix: fmt

* fix: qa

* fix: headers

* feat: locking

* fix: clippy

* fix: qa

* fix: frozen loc

* fix: shared instances diff calc break

* fix: env filter + remove categories
2026-08-13 14:55:35 +00:00
Sychic e087e571f3 fix(labrinth): disclosure modification attribution (#7121) 2026-08-13 14:51:22 +00:00
Calum H. fbdde8f3d8 feat: update required empty state for modrinth hosting (#7125) 2026-08-13 14:46:10 +00:00
Sychic 290bd2b934 chore: more idea sources (#7122) 2026-08-13 02:41:31 +02:00
Sychic 9ecbe8eaa6 feat(labrinth): granular disclosure locking (#7108)
* feat(labrinth): granular disclosure locking

* chore(labrinth): update query cache
2026-08-12 17:26:15 -07:00
Sychic 549b333eb2 feat(labrinth): project disclosures (#6945)
* feat(labrinth): project disclosures model

* feat(labrinth): project disclosures database model

* feat(labrinth): project disclosures get endpoint

* feat(labrinth): censor user ids if set by moderator

* feat(labrinth): wrap disclosures in struct

* feat(labrinth): edit project disclosures endpoint

* style(labrinth): cargo fmt

* style(labrinth): fix typo

* feat(labrinth): add fields for ai content disclosure

* fix(labrinth): field typo

* chore(labrinth): update query cache

* feat(labrinth): index disclosures in search

* refactor(labrinth): use enum instead of bools

* feat(labrinth): trigger incremental index on disclosure change

* feat(labrinth): change archived status to disclosure

* feat(labrinth): use disclosure for archival status

* fix(labrinth): error type for deserialization

* fix(labrinth): migration timestamp

* fix(labrinth): add disclosures to elasticsearch schema

* fix(labrinth): don't remove archival disclosure when changing status via v3 api

* feat(labrinth): derive str for ai usages and telemtry consent

* feat(labrinth): include ai usages and telemetry consent in disclosure types

* refactor(labrinth): move disclosure parsing to document creation

* fix(labrinth): prevent removing moderator added archival disclosures

* fix(labrinth): dedupe ai usages

* fix(labrinth): auth logic on archived disclosure removal

* feat(labrinth): add interactions field

* style(labrinth): cargo fmt

* feat(labrinth): soft delete disclosures

* feat(labrinth): return soft-deleted disclosures

* fix(labrinth): update disclosure on removal

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-12 17:26:14 -07:00
Prospector 4b82cca3bf fix sidebar not showing on user pages (#7097)
* fix sidebar not showing on user pages

* prepr
2026-08-12 17:25:26 -07:00
Prospector 734fd9cdb3 fix app build on windows & mac (#7118) 2026-08-12 17:25:15 -07:00
Prospector 2fd4495104 rejected projects go to ghost on delete (#7107)
* draft: rejected projects go to ghost on delete

* don't free slug

* fix sqlx

* free slugs but different

* add auto reject for processing items given to ghost, as well as send thread messages with a little info on who it came from
2026-08-12 23:54:57 +00:00
François-Xavier Talbot 552bc3f739 fix(labrinth): webhook scopes (#7114)
fix(labrinth): scope paypal & tremendous webhooks in payout scope
2026-08-12 18:34:33 +02:00
Truman Gao 4758cabbf9 feat: add hide and show download items (#7106)
* feat: add hide and show download items

* pnpm prepr
2026-08-12 16:18:02 +00:00
aecsocket 6ec979a076 fix: hide ad webview when showing fullscreen content (#7110)
* fix: hide ad webview when showing fullscreen content

* fixes
2026-08-12 16:02:02 +00:00
aecsocket c34bd26f87 fix: search indexing and schema changes (#7090)
* fix: incremental indexing uses latest db data

* dependent search fields
2026-08-12 11:54:35 -04:00
aecsocket 2a43792fd9 fix: app pinging servers over IPv6 (#7100)
fix: app pinging servers opver IPv6
2026-08-11 12:58:59 -04:00
Calum H. 2d567b93ce feat: imgur proxying for blocked countries (#7104) 2026-08-11 16:40:03 +00:00
Prospector 26e05ee9e5 update API docs to add environment fields (#7096)
* update API docs to add environment fields

* fix unknown on create/patch routes
2026-08-10 14:30:00 -07:00
jade ca8c5a717f fix: unique preview toggle ID in MarkdownEditor (#7093)
fix: preview toggle in MD editor targets other MD editors
2026-08-10 18:47:33 +00:00
Prospector 759f67a551 changelog 2026-08-10 11:17:45 -07:00
Prospector 66628fd69d add reddit and youtube as socials (#7092) 2026-08-10 11:17:13 -07:00
Prospector 1ffcd67562 changelog 2026-08-10 10:36:01 -07:00
Calum H. fda5c62bc6 fix: ignore extensions for mrpack warning modal (#6948) 2026-08-10 17:25:52 +00:00
aecsocket f8f05ce91b fix: expose raw icon URL in project response (#7086)
* fix: expose raw icon URL in project response

* remove unnecessary test
2026-08-10 15:48:23 +00:00
Calum H.andProspector 8b2438aebb fix: Gifs in the project description are not working properly (#7076)
* fix: Gifs in the project description are not working properly
Fixes #3786

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-10 15:43:13 +00:00
aecsocket 07bac8ffde fix: v3 environment fields in all v2 endpoints (#7082) 2026-08-10 15:42:56 +00:00
Calum H. d0856d46f2 fix: copy on unknown file warning modal (#7078)
Closes: #6865
2026-08-10 15:26:23 +00:00
Calum H. e8aee15664 chore: cleanup standards (#6970)
* chore: cleanup standards & skills

* remove: figma mcp doc, not needed anymore
2026-08-10 15:15:44 +00:00
Calum H. 98cb6b6b44 fix: buttons qa (#7067)
* fix: broken minecraft required modal button

* fix: app close icon issue

* fix: versions tooltip missing

* fix: instance not found bug on btn press
2026-08-10 15:15:27 +00:00
coolbot f27387462e Moderation: Rule placeholders with subsections & anchors, update messages. (#7068)
Rule placeholders with subsections & anchors, update messages.
2026-08-10 14:31:16 +00:00
coolbot ef90f08813 Moderation: Metadata Message (#7066)
* game versions msg

* sneak in some tec rev quick reply updates

* add loaders msg to metadata stage

* fix loaders msg
2026-08-10 14:31:03 +00:00
Calum H. 9fd45ef2ce fix: always check for updates/clear cache 10 min cooldown (#7077) 2026-08-10 14:00:42 +00:00
Michael H. 92971a3e0d build: don't auto update staging 2026-08-10 14:57:05 +02:00
Modrinth Bot 357224f22a New translations from Crowdin (main) (#7071) 2026-08-10 11:43:08 +00:00
Seungyeop Lee e2612d1a33 fix sidebar closing when collapsing friends list sections (#7060) (#7062) 2026-08-10 11:32:18 +00:00
coolbot 318840e403 Fix: Version nag navigation (#7070)
* fix: version nag navigation

* prepr
2026-08-10 10:54:29 +00:00
Prospector 1a56233c96 changelog 2026-08-08 12:10:41 -07:00
Truman Gao ff8c4f16a9 fix: action bar max width (#7048)
* fix: action bar max width

* fix: width
2026-08-08 19:06:42 +00:00
Truman Gao 757ec9ab53 fix: mrpack exporting with zip64 (#7064) 2026-08-08 17:34:54 +00:00
Truman Gao f38d351d32 fix: version upload failing to detect mrpack loader (#7063) 2026-08-08 17:18:58 +00:00
ThatGravyBoat b78dd9bf1b fix: missing delphi severity (#7054) 2026-08-07 18:16:13 -07:00
chyz 5072c1d298 Moderation Changes Phase 2.-1 (#6833)
* better default state handling

* make next stage button work for going to next project

* fix skill issue

* actually fix initial values this time,
make single line inputs with varying lengths (text input and dropdown) use their widest possible length instead of whatever the hell they used to be

* loaders in progress

* loaders and versions test (probably dont use them as the messages suck and idrk what they should do quick fix/message wise)

* WIP the wip

* good enough for now slug taken improvements

* trim all those extra newlines

* viewedness

* batch prefetch queue + technically slightly faster checklist init

* show all valid alternative project types regardless of current project type

* Hide moderation permissions stage when it should be, and I'm sure this won't cause any issues

* Hide moderation permissions stage when it should be, and I'm sure this won't cause any issues

* gimme them versions

* better moderation permission stage shown check?

* betterer moderation permission stage shown check?

* bettererer moderation permission stage shown check? + also began deleting legacy checklist types

* fix certain stages being able to be considered viewed before being viewed

* more removals + i forgot to remove an import last commit

* changed nothing™️

* remove severities + bandaid post-approval message priorities + dependencies

* fix message preview tooltips

* next stage = next project when done

* insufficient description custom fix

* fix that thing coolbot told me was broken I forgot what it was oops

* oh there was a second bug there

* make alternate versions and incorrect project type required cuz they are

* re-navigate button also damn that's a lot of other stuff that pnpm fix... fixed?

* you cant just quick fix a slug into a taken one

* maybe lets not just try and set a slug to something entirely invalid?

* what if we just didn't drop ur queue :smart:

* more queue upgrades

* tweaks

* I love unbreaking things

* un-nuked renavigate button

* prepr

* prepr worked this time i think

* undo more mistakes

* i love intellij refactoring

* ok i think we're good
2026-08-07 23:40:11 +00:00
Prospector e4cbec6b2c changelog 2026-08-07 13:42:57 -07:00
ThatGravyBoat 5ef999bf8a feat: requested moderation and tech review changes (#7019)
* feat: requested moderation and tech review changes

* chore: run prepr

* chore: run prepr

---------

Signed-off-by: ThatGravyBoat <gravy@thatgravyboat.tech>
2026-08-07 13:34:03 -07:00
Prospector b0394de164 add loading states for creators + server details (#7033)
* add loading states for creators + server details

* prepr
2026-08-07 13:20:31 -07:00
Prospector 9181c155e0 fix license field showing on server projects (#7031)
* fix license field showing on server projects

* random commit to trigger CI hopefully?
2026-08-07 13:20:13 -07:00
Prospector 2e8aaa31be change admin edit role modal to edit user (#7035)
change edit role modal to edit user
2026-08-07 13:20:05 -07:00
Prospector bca5303ae3 improve popout animations (#7040) 2026-08-07 13:19:56 -07:00
Prospector 75335647ee update tags page + sort project types consistently (#7044) 2026-08-07 13:19:36 -07:00
Blodhgarm a71a361e0f feat: Allow for adjustable alignment of main content on certain pages for content moderators (#7034)
* Add type safety and validation for moderation settings

* Add ability for moderators to adjust page layout for discover, project, moderation pages

Primarily for changes to have more tooling present without overlapping important info
2026-08-07 16:45:37 +00:00
Truman Gao 6ed044a54b fix: multiple compatible downloads showing as radio button (#7050) 2026-08-07 16:04:04 +00:00
aecsocket 3fa6905006 refactor: labrinth ApiError and error reporting (#6981)
* refactor: labrinth `ApiError` and error reporting

* fix clippy

* fix ci
2026-08-07 15:47:54 +00:00
Prospector d344cbcb7a make neoforge icon look more fox-like, replace hosting LoaderIcon with TagIcon (#7045)
make neoforge icon look more fox-like, replace hosting LoaderIcon with ui lib TagIcon
2026-08-07 15:29:25 +00:00
Prospector 204f4da33f fix edit profile in app not working (#7046) 2026-08-07 15:16:09 +00:00
aecsocket c57e88ecd4 fix: improve analytics dependents query (#7025)
* fix: improve analytics dependents query

* fix up compose
2026-08-07 10:33:15 -04:00
Michael H. feb3c3ee31 build: trigger labrinth ci on packagess 2026-08-07 11:47:47 +02:00
aecsocket 09f2af61dc fix: Mural payout status schema (#7038) 2026-08-07 11:05:13 +02:00
ArthurandProspector 61b52ecd2c fix: inconsistent color vars (#7028)
* Fix inconsistent color vars

* pointless commit

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>

* undo

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>

* prepr

---------

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-07 06:30:42 +00:00
ArthurandProspector 9625740906 fix: anchored teleport (#7027)
* Fix anchored teleport

* pointless commit

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>

* undo

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>

* prepr

---------

Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-07 06:29:06 +00:00
Calum H. f234b7a0ac fix: broken plan selector colors (#7013) 2026-08-07 04:43:17 +00:00
Mr_chank 8aa22b9bed refactor(i18n): use common messages for generic button labels (#7020)
A few components defined their own "Cancel", "Close", "Clear" and
"Success" descriptors when commonMessages already has them. Swapped
them over and removed the old ones.

Locale files come from intl:prune-local dropping the now-orphaned
strings across every language.
2026-08-07 04:39:05 +00:00
Michael H. b8d97268cb perf: further reduce memory (#7015)
* feat: server source maps

* perf: strip unused fields from search results

* perf: reduce game version memory usage
2026-08-06 15:39:06 +00:00
Calum H. c671ca52f6 fix: dropdown panel at 0,0 (#7024) 2026-08-06 15:10:46 +00:00
Michael H. a89711841d chore: reduce frontend server memory & tracing (#7014)
* chore: reduce memory usage

* feat: worker tracing

* fix: eslint

* fix: remove compat flag

* fix: prettier
2026-08-05 19:59:15 +02:00
Calum H. 0038ea8b15 fix: remove legacy button classes (#7011)
* fix: remove legacy button-group class for tailwind (conflicts)

* fix: delete legacy button classes from scss

* fix: fmt
2026-08-05 16:34:00 +02:00
Calum H. c0e6f094d9 fix: panel issues w/ backups (#7009) 2026-08-05 13:17:29 +00:00
Calum H. 1c574a1f2d fix: affiliates showing when not meant to (#7008) 2026-08-05 14:22:05 +02:00
Calum H. bca8b1a02b fix: moderation queue endpoint regression (#7003)
fix: regression caused by 2308ac1 changes
2026-08-05 10:47:06 +00:00
aecsocket 5148e8ec35 feat: use postcard for redis serde (#6956)
* redo error handling in xredis

* give proper types to metadata fields

* add round-trip tests

* inline loader enum metadata fields

* postcard roundtrips

* prepare

* bump redis key version

* serde-binhum

* clippy

* fix

* fix frontend checking existence of component fields rather than non-null-ness

* prepare
2026-08-05 10:36:05 +00:00
SlackowandProspector cc53d9a6f8 feat: support supplementary environment variables for hooks in App (#5916)
* feat: Implement launcher supplementary environmental variables, and env var substitution in hooks

* lint fix, prettier reformat

* descriptions added for each environment variable for hook settings

* alphabetize language keys

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-04 22:23:07 +00:00
coolbot 474c6560fc feat: update gallery nags (#6999)
* update nag to require 3 images from shader projects.

* locales
2026-08-04 21:33:26 +00:00
Calum H. f42a3bfde3 fix: moderation fixes (#6952)
* fix: skip deleted projects

* fix: remove unnesecary blocker
2026-08-04 21:33:17 +00:00
Calum H.andProspector 540b3269df fix: breadcrumb issue on browse content via server panel (#6971)
fix: breadcrumb issue on browse/navigation bar

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-04 21:32:52 +00:00
Calum H. 36857a167b fix: content selection bar not showing in ModpackContentModal (#6950) 2026-08-04 21:24:43 +00:00
LarpinWBDandClaude 3487523f74 fix: version tags toggling every supported version and freezing the page (#6967)
Fixes #6601.

Clicking any game version tag on the versions tab passed the release's
entire game_versions array to toggleFilters, so clicking 26.x (or even a
single version tag) selected every version the release supports. On
projects supporting many versions this also froze the page for seconds:
toggleFilters awaited a needlessly-async toggleFilter per version, which
let Vue flush a full re-render between every iteration (~90 flushes on
the reported project).

- formatVersionsForDisplay's grouping now also returns the member
  versions per displayed range (getVersionGroupsForDisplay), so version
  tags toggle exactly the versions they represent
- toggleFilters applies the whole batch synchronously in one update, and
  selects the missing versions unless all are already selected (instead
  of blindly inverting each one)

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-04 21:12:57 +00:00
Calum H.andProspector bb2193b6f5 refactor: Button components (#6929)
* feat: refactor button components

* fix: qa

* fix: storybook

* a11y: pass

* fix: build

* fix: rename default ->md

* fix: lint

* docs: buttons.md

* refactor part 1

* refactor part 2

* fix: undo refactor for fresh restart

* refactor

* Revert "refactor"

This reverts commit 96d65902d7.

* refactor: part 1

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: remove text-contrast

* fix: qa

* fix: v-tooltip

* fix: lint

* fix: split broken

* fix: passkey qa

* fix: qa

* fix: qa

* fix: prepr

* fix: splitbutton

* improve button group seam

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-04 13:54:04 -07:00
Calum H. 6cfe999cb3 fix(shared-instances): allow nbt configs (#6993)
fix: allow nbt configs
2026-08-04 18:01:21 +00:00
Modrinth Bot 893ab49fdf New translations from Crowdin (main) (#6972) 2026-08-04 17:59:39 +00:00
Calum H.andtdgao fe0c97190a refactor: use DI for instance page + subpages (#6987)
* refactor: use DI for instance page + subpages

* fix: qa

* refactor: layout.vue bring up a layer

* refactor: rename folders

* import order

* refactor: move settings into instance page

* fix: lint

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-08-04 16:52:00 +00:00
Michael H. 99377f8436 Update merge queue configuration (#6989)
Signed-off-by: Michael H. <michael@iptables.sh>
2026-08-04 16:03:28 +00:00
Calum H. 4c3abc62a8 fix: export modal path handling (#6949)
* fix: export modal path handling

* fix: prepr

* fix: NEVER_EXPORTABLE_PATH_SUFFIXES list
2026-08-04 15:34:20 +00:00
Calum H. af7336fffb fix: show "inspecting modpack" (#6979)
* fix: show "inspecting modpack"

Shows when determining external files for modpacks which have many overrides/over gb of content files.

* fix: better progress indicator
2026-08-04 15:21:31 +00:00
Calum H. cfea3aba34 fix: mc account logout on stale (#6986) 2026-08-04 14:29:03 +00:00
aecsocket 6715a9c679 fix: global traces QA notes (#6977)
* updated at cols + search by path

* speed up scanning

* migration does not auto-set updated_at

* merge migrations
2026-08-03 18:15:13 +00:00
aecsocket c3249ee51d feat: ElasticSearch backend (#6903)
* (do not merge) search test branch

* perf

* fix up search pagination

* more parity and perf work

* expand what parity does

* fix author query

* approach parity even without explicit parity enabled

* remove old parity code

* fix shear

* fix

* fmt
2026-08-03 17:14:07 +00:00
Prospector 2e43f6a42b collapse offline friends by default, save collapse value (#6937) 2026-08-03 07:03:45 +00:00
Prospector 779edf0333 Fix server ping race condition before protocol version is available (#6935)
* fix race condition with server ping lookup before protocol resolved

* fix refresh button on worlds page
2026-08-03 07:00:54 +00:00
coco 🐾 ae13d37edc docs: corrections to GetLatestVersionFromHash and GetLatestVersionsFromHashes documentation (#6966)
correct version_files documentation
2026-08-02 23:49:19 +00:00
ThatGravyBoat 1e49d7da7a fix: missing auth providers in account details (#6965)
* fix: missing auth providers in account details

This was missing due to a bad merge in #6889 not merging in #6897 correctly

* chore: run intl:extract
2026-08-02 22:23:03 +00:00
Michael H. 8b753a52ad fix: build labrinth on prod again 2026-07-31 14:45:16 +02:00
Michael H. a53c9c3771 fix: only deploy if internal 2026-07-31 13:21:35 +02:00
Michael H. 919b235621 chore: address action issues 2026-07-31 13:17:26 +02:00
Michael H. 18b0b2848d feat: labrinth argo 2026-07-31 13:10:58 +02:00
Prospector 65a3ac4b34 changelog 2026-07-30 23:10:28 -07:00
Prospector 295db3fb7a only refresh tokens and clear auth cookies on auth fails, not any ran… (#6947)
only refresh tokens and clear auth cookies on auth fails, not any random error
2026-07-30 23:10:08 -07:00
Michael H. 7abe6f8c73 feat: sccache on windows 2026-07-30 20:27:08 +02:00
Michael H. c5ce5bc9b3 fix: create clickhouse db in tests only 2026-07-30 20:08:49 +02:00
Michael H. 572b8027ff fix: clickhouse migrations should not run normally 2026-07-30 19:23:10 +02:00
aecsocket 0a53211389 fix: analytics event endpoint (#6938)
* fix: analytics event endpoint

* split admin-only routes into internal

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

* prepr

* prepr again

---------

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

* fix: cleanup impl

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

* make false by default
2026-07-29 17:54:21 +00:00
1242 changed files with 67642 additions and 34110 deletions
+23
View File
@@ -0,0 +1,23 @@
---
name: api-module
description: Add an API endpoint module to packages/api-client from an OpenAPI schema. Use for new backend endpoints, API client modules, or tasks that provide an OpenAPI schema.
---
# Add an API Module
Read the applicable `AGENTS.md` files before you edit code.
Read [the API module standard](../../../standards/frontend/ADDING_API_MODULES.md) in full.
1. Identify the OpenAPI schema from the request. If more than one schema is possible, ask the user to select one.
2. Read the schema. Identify each endpoint, HTTP method, request type, response type, and path parameter.
3. Get the service and version from the URL prefix. For example, map `/v3/projects` to `labrinth/v3/`.
4. Define the API types in `types.ts`. Make each type match the schema exactly.
5. Do not change, rename, or remove API fields.
6. Make a module class that extends `AbstractModule`. Implement each endpoint with `this.client.request()` or `this.client.upload()`.
7. Use the request-option pattern from the standard. Do not call `$fetch`, `fetch`, or another HTTP client directly.
8. Add the module to `MODULE_REGISTRY` so the client can instantiate it.
9. Export new service types from the applicable barrel `index.ts`.
10. Check the module paths, registry key, public type exports, and endpoint types.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Add API Module"
short_description: "Add typed API client modules from OpenAPI"
default_prompt: "Use $api-module to add an API client module from this OpenAPI schema."
@@ -0,0 +1,38 @@
---
name: cross-platform-pages
description: Convert a page to the shared Modrinth page system for the website and desktop app. Use for shared layouts, wrapped layouts, or platform dependency-injection contracts.
---
# Convert a Cross-Platform Page
Read the applicable `AGENTS.md` files before you edit code.
Read these standards in full:
- [Cross-platform pages](../../../standards/frontend/CROSS_PLATFORM_PAGES.md)
- [Dependency injection](../../../standards/frontend/DEPENDENCY_INJECTION.md)
1. Identify the target page from the request.
2. Read the page and its route shell. Identify data sources, mutations, navigation, and platform APIs.
3. Use a wrapped layout when both platforms use the same API source and page logic.
4. Use a shared layout when platform data or operations have different implementations.
For a shared layout:
1. Define a provider contract for all platform operations.
2. Put common UI and state logic in the shared layout.
3. Put reusable search, filter, and selection logic in local composables.
4. Implement the contract in `apps/frontend/` and `apps/app-frontend/`.
5. Use optional contract fields only for capabilities that are not available on both platforms.
For a wrapped layout:
1. Move the page to `packages/ui/src/layouts/wrapped/` and preserve its route structure.
2. Replace platform-only imports with common utilities or provider calls.
3. Make each frontend route shell render the wrapped component.
4. Match primary query options in both route shells when the layout uses `ReadyTransition` and `useReadyState`.
5. Prefetch these queries with `ensureQueryData`, as the standard specifies.
Check that both route shells resolve their imports. Check that all required provider fields have implementations.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Convert Cross-Platform Page"
short_description: "Share pages across the web and desktop app"
default_prompt: "Use $cross-platform-pages to convert this page for the website and desktop app."
+21
View File
@@ -0,0 +1,21 @@
---
name: figma-mcp
description: Convert a Figma design into a Modrinth Vue page or component. Use when a request provides a Figma URL or asks to implement a Figma layout.
---
# Implement a Figma Design
Read the applicable `AGENTS.md` files before you edit code.
Read `packages/ui/AGENTS.md` in full.
1. Load the available Figma design-to-code instructions and follow the MCP tool guidance.
2. Call `get_design_context` first with `clientLanguages: "typescript,html,css"` and `clientFrameworks: "vue"`.
3. Treat the result as reference code and adapt it to the Modrinth codebase.
4. Map Figma color variables to the applicable `surface-*` and `text-*` tokens. Do not use aliased Figma names directly.
5. Reuse applicable components from `packages/ui/src/components/` before creating new ones. Also refer to `standards/frontend/COMPONENT_STRUCTURE.md`
6. Read `packages/assets/styles/variables.scss` when Figma does not supply a required token.
7. Use exact spacing values from the design.
8. Implement the result as a Vue SFC with Tailwind classes and the existing component library.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Implement Figma Design"
short_description: "Build Modrinth Vue UI from Figma designs"
default_prompt: "Use $figma-mcp to implement this Figma design as a Modrinth Vue component."
+32
View File
@@ -0,0 +1,32 @@
---
name: i18n-pass
description: Convert hard-coded English text in changed Vue components to the @modrinth/ui localization system. Use for an i18n pass, untranslated-string review, pull request, or component migration.
---
# Do an Internationalization Pass
Read the applicable `AGENTS.md` files before you edit code.
Read [the internationalization standard](../../../standards/frontend/INTERNATIONALIZATION.md) in full.
1. Identify the scope from the request.
2. For a pull request, use `gh pr diff <number>` to identify changed files.
3. For a file path, inspect that file.
4. When the request gives no scope, inspect the current uncommitted diff.
5. Limit the pass to changed `.vue` files.
6. Find user-visible text in templates and scripts.
Check inner text, `alt`, `placeholder`, `aria-label`, buttons, tooltips, notifications, dropdown labels, and error messages.
Do not change dynamic expressions, HTML tag names, CSS classes, internal identifiers, or log messages.
1. Define stable message IDs with `defineMessage` or `defineMessages`.
2. Replace simple text with `formatMessage()` calls.
3. Use `<IntlFormatted>` for text that contains links or markup.
4. Use ICU selections and plurals when grammar depends on a value.
5. Add a space before `}}` when an ICU placeholder ends at the Vue delimiter.
6. Do not change component logic, layout, or reactivity.
7. Do not edit localization JSON files. The user maintains those files.
8. Check the changed templates again for hard-coded English text.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Run Internationalization Pass"
short_description: "Localize user-visible text in Vue files"
default_prompt: "Use $i18n-pass to localize the user-visible text in these changed Vue files."
+40
View File
@@ -0,0 +1,40 @@
---
name: review-changelog
description: Review the latest packages/blog/changelog.ts entry against the Modrinth changelog standard. Use before a pull request or when asked to review or lint a changelog entry.
---
# Review a Changelog Entry
Read [the changelog standard](../../../standards/maintaining/CHANGELOG.md) in full before the review.
1. Open `packages/blog/changelog.ts`.
2. Find the first entry in the `VERSIONS` array.
3. If the request names `web`, `hosting`, or `app`, review the latest entry for that product.
4. Otherwise, review the latest entry and all adjacent entries with the same date.
Check the entry structure:
- `date` contains a valid ISO 8601 timestamp.
- `product` is `web`, `hosting`, or `app`.
- An `app` entry has a `version` value.
- A `web` or `hosting` entry does not have a `version` value.
- Standard headings are `## Added`, `## Changed`, `## Fixed`, and `## Security`.
- A featured release can use a linked heading.
- Flag the legacy `## Improvements` heading.
Check each bullet:
- The voice and tense agree with the section.
- The first verb agrees with the section.
- The bullet describes user-visible behavior, not implementation.
- The bullet identifies the applicable page, tab, modal, or feature.
- The bullet contains one sentence, uses sentence case, and ends with a period.
- Product and UI names use the public labels.
- The bullet does not contain filler, vague intensifiers, apologies, or internal references.
- The bullet is not a duplicate detail of a larger listed change.
Group findings by entry. For each finding, show the original bullet and a proposed replacement.
If the entry has no findings, state this result. Do not edit the changelog unless the user asks you to apply fixes.
When the user asks for fixes, preserve tab indentation and template-literal formatting.
@@ -0,0 +1,4 @@
interface:
display_name: "Review Changelog"
short_description: "Review changelog entries for style problems"
default_prompt: "Use $review-changelog to review the latest changelog entry."
+39
View File
@@ -0,0 +1,39 @@
---
name: tanstack-query
description: Convert Vue server-state code to TanStack Query. Use for useQuery, useMutation, cache invalidation, optimistic updates, or replacement of useAsyncData and manual ref patterns.
---
# Convert Data Code to TanStack Query
Read the applicable `AGENTS.md` files before you edit code.
Read [the TanStack Query standard](../../../standards/frontend/FETCHING_DATA.md) in full.
1. Identify the target file from the request.
2. Find `useAsyncData`, `useFetch`, manual API refs, and fetch calls in `onMounted`.
3. Identify mutations that use manual loading, error, or result refs.
For queries:
1. Replace manual fetch logic with `useQuery`.
2. Get `api-client` with `injectModrinthClient()`.
3. Use a hierarchical query key with the resource, qualifier, and parameters.
4. Use a computed query key for reactive parameters.
5. Use a computed `enabled` option when the query depends on other data.
6. Use a shared query-option factory when multiple components use the query.
For mutations:
1. Replace manual mutation state with `useMutation`.
2. Invalidate or update related query data after success.
3. Use an optimistic update only when the UI needs an immediate response.
4. Cancel the applicable query and save its prior data before an optimistic update.
5. Restore the prior data after an error. Invalidate the query after settlement.
Remove manual loading and error refs that TanStack Query replaces. Remove obsolete `onMounted` fetch calls.
Keep Nuxt SSR behavior. Match route-shell prefetch options when `ReadyTransition` and `useReadyState` depend on the query.
Check query keys, invalidation prefixes, reactive values, and rollback data.
Run only the checks that the user or the applicable `AGENTS.md` permits.
@@ -0,0 +1,4 @@
interface:
display_name: "Migrate to TanStack Query"
short_description: "Migrate Vue server state to TanStack Query"
default_prompt: "Use $tanstack-query to migrate this Vue component to TanStack Query."
-18
View File
@@ -1,18 +0,0 @@
---
name: api-module
description: Add a new API endpoint module to packages/api-client from an OpenAPI schema. Use when adding new backend endpoints, creating API client modules, or when an openapi.yml is provided.
argument-hint: <path-to-openapi.yml>
---
Refer to the standard: @standards/frontend/ADDING_API_MODULES.md
## Steps
1. **Read the OpenAPI schema** at `$ARGUMENTS` — identify the endpoints, request/response shapes, and path parameters.
2. **Read the standard above** for naming conventions, type rules, and the module registration pattern.
3. **Determine the service and version** — the URL path prefix tells you which service directory and version namespace to use (e.g. `/v3/projects``labrinth/v3/`).
4. **Define types in `types.ts`** — types must match the API response 1:1. Use the OpenAPI schema as the source of truth. Do not reshape or rename fields.
5. **Create the module class** — extend `BaseModule`, implement each endpoint as a method. Use the correct HTTP verb and request options pattern from the standard.
6. **Register in `MODULE_REGISTRY`** — add the module entry so it's auto-instantiated on the client.
7. **Export types** from the service's barrel `index.ts`.
8. **Verify** — check that the module compiles and the types are accessible from `@modrinth/api-client`.
@@ -1,26 +0,0 @@
---
name: cross-platform-pages
description: Convert a page to the cross-platform page system so it works in both the website and the desktop app. Use when moving a page into packages/ui/src/layouts/, creating shared or wrapped layouts, or setting up DI contracts for platform abstraction.
argument-hint: <path-to-page>
---
Refer to the standards: @standards/frontend/CROSS_PLATFORM_PAGES.md and @standards/frontend/DEPENDENCY_INJECTION.md
## Steps
1. **Read the target page** at `$ARGUMENTS` and understand its data sources, mutations, and navigation.
2. **Read the standards above** to understand the shared vs wrapped distinction and the DI pattern.
3. **Decide the category:**
- **Wrapped** (`layouts/wrapped/`) — if the page uses the same API source on both platforms (e.g. web requests, not Tauri plugins). Just move the page component into `packages/ui` and import it from both frontends.
- **Shared** (`layouts/shared/`) — if the page has different data-fetching logic per platform (e.g. website uses `api-client`, app uses Tauri `invoke`). Requires a DI contract.
4. **For shared layouts:**
- Define a DI contract interface in `providers/` capturing all platform-specific operations.
- Create the layout component that injects the context and handles all UI logic.
- Extract reusable stateful logic (search, filtering, selection) into `composables/`.
- Implement the contract separately in each frontend (`apps/frontend/`, `apps/app-frontend/`).
5. **For wrapped pages:**
- Move the page component into `packages/ui/src/layouts/wrapped/` matching the route structure.
- Replace any platform-specific imports with shared utilities.
- Import and render the wrapped page from both frontends as a simple component.
- If the layout uses TanStack Query for initial route paint with `ReadyTransition` / `useReadyState`, each platform route shell must call `ensureQueryData` for those queries with matching keys and fetchers — see **Platform route shells: prefetch with `ensureQueryData`** in `standards/frontend/CROSS_PLATFORM_PAGES.md`.
6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied.
-22
View File
@@ -1,22 +0,0 @@
---
name: figma-mcp
description: Use the Figma MCP server to translate a Figma design into a Vue page or component layout. Use when the user provides a Figma URL, asks to implement a design, or wants to draft a page layout from Figma.
argument-hint: <figma-url>
---
Refer to the standard: @standards/frontend/FIGMA_MCP_USAGE.md
Also read @packages/ui/CLAUDE.md for color token mapping and component conventions.
## Steps
1. **Parse the Figma URL** from `$ARGUMENTS` — extract the `fileKey` and `nodeId`. Convert `-` to `:` in the node ID.
2. **Read the standards above** for the available tools, adaptation rules, and color usage.
3. **Call `get_design_context`** with the extracted `nodeId` and `fileKey`, using `clientLanguages: "typescript,html,css"` and `clientFrameworks: "vue"`. This is always the first tool to call.
5. **Adapt the output to the Modrinth codebase:**
- Map Figma color variables to `surface-*` / `text-*` tokens — never use Figma's aliased names directly.
- Check `packages/ui/src/components/` for existing components that match elements in the design (buttons, cards, modals, inputs, etc.).
- Check `packages/assets/styles/variables.scss` for tokens not exposed in Figma.
- Match spacing values exactly from the design.
6. **Use `get_screenshot`** if you need a closer visual reference of specific nodes.
7. **Use `get_variable_defs`** to verify which design tokens are applied to ambiguous elements.
8. **Build the component** as a Vue SFC using Tailwind classes and the project's existing component library.
-24
View File
@@ -1,24 +0,0 @@
---
name: i18n-pass
description: Perform an i18n localization pass on changed files or a pull request, converting hard-coded English strings to the @modrinth/ui i18n system. Use when internationalizing a set of changes, reviewing a PR for untranslated strings, or converting a specific component.
argument-hint: [file-path-or-pr-number]
---
Refer to the standard: @standards/frontend/INTERNATIONALIZATION.md
## Steps
1. **Identify the scope of changes:**
- If `$ARGUMENTS` is a PR number, run `gh pr diff $ARGUMENTS` to get the changed files.
- If `$ARGUMENTS` is a file path, use that directly.
- If no argument, check `git diff` for uncommitted changes.
2. **Read the standard above** for the message definition pattern, ICU format rules, and `IntlFormatted` usage.
3. **Filter to Vue SFCs** — only `.vue` files need i18n passes. Skip non-component files.
4. **For each file, scan for hard-coded strings:**
- `<template>`: inner text, `alt`, `placeholder`, `aria-label`, button labels, tooltip text.
- `<script>`: string literals passed to user-visible UI (notification messages, dropdown labels, error messages).
- Skip: dynamic expressions, HTML tag names, CSS classes, internal identifiers, log messages.
5. **Define messages** with `defineMessages` — use descriptive, stable `id`s based on the component's domain (e.g. `project.settings.title`).
6. **Replace strings in templates** with `formatMessage()` calls, or `<IntlFormatted>` for strings containing links or markup.
7. **Handle ICU edge cases** — add a space before `}}` if an ICU placeholder ends at a Vue template delimiter boundary.
8. **Verify** no hard-coded English strings remain in the changed templates. Do not alter logic, layout, or reactivity.
-36
View File
@@ -1,36 +0,0 @@
---
name: review-changelog
description: Review the latest changelog entry in packages/blog/changelog.ts against the project's changelog style guide and flag bullets that need rewriting. Use when checking a freshly added changelog entry before opening a PR, or when the user asks to review/lint the latest changelog.
argument-hint: [product?]
---
Refer to the standard: @standards/maintaining/CHANGELOG.md
## Steps
1. **Locate the latest entry:**
- Open `packages/blog/changelog.ts`.
- The latest entries are at the top of the `VERSIONS` array.
- If `$ARGUMENTS` specifies a product (`web`, `hosting`, `app`), review the most recent entry for that product. Otherwise, review the most recent entry overall, plus any sibling entries sharing the same `date` (coordinated releases ship together).
2. **Read the standard above** in full before reviewing. The bullet rules, section/verb agreement, and "Don't" list are the source of truth.
3. **Check the entry shell:**
- `date` is a valid ISO 8601 timestamp.
- `product` is one of `web`, `hosting`, `app`.
- `version` is present for `app` entries and omitted for `web`/`hosting`.
- Section headings use `## Added`, `## Changed`, `## Fixed`, `## Security` (or a featured-release linked heading). Flag legacy `## Improvements`.
4. **Review each bullet** against the standard. For each bullet, check:
- Voice/tense matches the section heading.
- Opening verb agrees with its section.
- Describes observable behavior, not implementation.
- Specific enough to identify the surface (names the tab/page/modal).
- One sentence, ends with a period, sentence case.
- Uses branded names (Modrinth App, Modrinth Hosting) correctly.
- No filler ("issue with", "issue where", "various", "some"), no vague intensifiers, no apologies, no PR/commit references (unless crediting a third-party contributor with a linked GitHub profile).
- Not a duplicate sub-fix of a bigger change already listed.
5. **Report findings** as a short list grouped by entry. For each problem bullet, show the original line and a suggested rewrite. If the entry is clean, say so explicitly. Do not edit the file unless the user asks - this skill is a review pass, not a rewrite pass.
6. **If the user then asks to apply fixes**, edit `packages/blog/changelog.ts` directly using the suggested rewrites. Preserve tab indentation and template literal formatting.
-27
View File
@@ -1,27 +0,0 @@
---
name: tanstack-query
description: Convert a page or component from useAsyncData/manual ref patterns to TanStack Query for server state management. Use when migrating data fetching to useQuery/useMutation, adding cache invalidation, or replacing useAsyncData with TanStack Query.
argument-hint: <path-to-file>
---
Refer to the standard: @standards/frontend/FETCHING_DATA.md
## Steps
1. **Read the target file** at `$ARGUMENTS` and identify all data-fetching patterns: `useAsyncData`, `useFetch`, manual `ref()` + `await`, or `onMounted` fetch calls.
2. **Read the standard above** for the query/mutation patterns, query key conventions, and optimistic update approach.
3. **Convert queries:**
- Replace `useAsyncData` / `useFetch` / manual fetches with `useQuery`.
- Use the `api-client` via `injectModrinthClient()` for the `queryFn`.
- Design query keys with the `['resource', 'version', ...params]` convention.
- Use `computed` query keys for reactive parameters.
- Use the `enabled` option for conditional queries that depend on other data.
4. **Convert mutations:**
- Replace manual `try/catch` + `ref` patterns with `useMutation`.
- Add `onSuccess` handlers that invalidate or update related query caches.
- Consider optimistic updates for UI-critical mutations (follow the pattern in the standard).
5. **Clean up:**
- Remove manual loading/error `ref()`s that are now handled by TanStack Query's return values (`isPending`, `isError`, `error`).
- Remove manual `onMounted` fetch calls.
- Ensure SSR compatibility — queries in Nuxt pages are automatically awaited during SSR.
6. **Verify** the page still renders correctly and that cache invalidation triggers re-fetches where expected.
+21 -11
View File
@@ -34,13 +34,13 @@ runs:
using: 'composite' using: 'composite'
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Extract PR Number and Commit ID - name: Extract PR Number and Commit ID
id: extract-pr-info id: extract-pr-info
uses: actions/github-script@v7 uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with: with:
script: | script: |
const githubRef = process.env.GITHUB_REF; const githubRef = process.env.GITHUB_REF;
@@ -90,20 +90,26 @@ runs:
- name: Print PR Branch - name: Print PR Branch
if: env.CAN_SKIP_CHECKS != 'false' if: env.CAN_SKIP_CHECKS != 'false'
shell: bash shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: | run: |
echo "PR Branch: ${{ steps.get-pr-branch.outputs.prBranch }}" echo "PR Branch: $PR_BRANCH"
- name: Check if PR branch contains the Merge Queue target commit ID - name: Check if PR branch contains the Merge Queue target commit ID
if: env.CAN_SKIP_CHECKS != 'false' if: env.CAN_SKIP_CHECKS != 'false'
shell: bash shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
COMMIT_ID: ${{ steps.extract-pr-info.outputs.commitId }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
run: | run: |
# Get the branch name from previous steps # Get the branch name from previous steps
branch_name="origin/${{ steps.get-pr-branch.outputs.prBranch }}" branch_name="origin/$PR_BRANCH"
commit_id="${{ steps.extract-pr-info.outputs.commitId }}" commit_id="$COMMIT_ID"
# Check if the branch history contains the commit # Check if the branch history contains the commit
if git branch -r --contains "$commit_id" | grep -q "$branch_name"; then if git branch -r --contains "$commit_id" | grep -qF "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with ${{ steps.extract-pr-info.outputs.targetBranchName }}." echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with $TARGET_BRANCH."
else else
echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false." echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false."
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV" echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
@@ -112,8 +118,10 @@ runs:
- name: Compare PR Branch with Current Branch - name: Compare PR Branch with Current Branch
if: env.CAN_SKIP_CHECKS != 'false' if: env.CAN_SKIP_CHECKS != 'false'
shell: bash shell: bash
env:
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
run: | run: |
if git diff --quiet "origin/${{ steps.get-pr-branch.outputs.prBranch }}"; then if git diff --quiet "origin/$PR_BRANCH"; then
echo "No differences found. PR branch is identical with this merge queue branch." echo "No differences found. PR branch is identical with this merge queue branch."
else else
echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false." echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false."
@@ -122,9 +130,11 @@ runs:
- name: Compute/publish skip result - name: Compute/publish skip result
id: passed-checks id: passed-checks
uses: actions/github-script@v7 uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env: env:
SECRET: ${{ inputs.secret }} SECRET: ${{ inputs.secret }}
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
with: with:
github-token: ${{ inputs.secret != '' && inputs.secret || github.token }} github-token: ${{ inputs.secret != '' && inputs.secret || github.token }}
script: | script: |
@@ -144,7 +154,7 @@ runs:
const { data: branchProtection } = await github.rest.repos.getBranchProtection({ const { data: branchProtection } = await github.rest.repos.getBranchProtection({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}", branch: process.env.TARGET_BRANCH,
}); });
const requiredCheckNames = branchProtection.required_status_checks.contexts; const requiredCheckNames = branchProtection.required_status_checks.contexts;
console.log(`requiredCheckNames = ${requiredCheckNames}`); console.log(`requiredCheckNames = ${requiredCheckNames}`);
@@ -152,7 +162,7 @@ runs:
const { data: checks } = await github.rest.checks.listForRef({ const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
ref: "refs/heads/${{ steps.get-pr-branch.outputs.prBranch }}", ref: `refs/heads/${process.env.PR_BRANCH}`,
}); });
console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`); console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`);
+24
View File
@@ -0,0 +1,24 @@
name: Deploy Command
run-name: Deploy PR #${{ github.event.client_payload.github.payload.issue.number }}
on:
repository_dispatch:
types: [deploy-command]
permissions:
contents: read
actions: read
jobs:
deploy:
if: ${{ github.event.client_payload.pull_request.head.repo.full_name == github.repository }}
uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
CMD_DISPATCH_GH_TOKEN: ${{ secrets.SLASH_CMD_GH_TOKEN }}
with:
application-set: labrinth
build-workflow: labrinth-build.yml
branch: ${{ github.event.client_payload.pull_request.head.ref }}
issue-number: ${{ github.event.client_payload.github.payload.issue.number }}
head-sha: ${{ github.event.client_payload.pull_request.head.sha }}
@@ -1,19 +1,26 @@
name: docker-build name: Labrinth Build
on: on:
push: push:
branches: branches:
- 'main' - 'main'
- 'prod'
paths: paths:
- .github/workflows/labrinth-docker.yml - .github/workflows/labrinth-build.yml
- 'apps/labrinth/**' - 'apps/labrinth/**'
- 'packages/**'
- '!packages/api-client/**'
- '!packages/app-lib/**'
- Cargo.toml - Cargo.toml
- Cargo.lock - Cargo.lock
pull_request: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
paths: paths:
- .github/workflows/labrinth-docker.yml - .github/workflows/labrinth-build.yml
- 'apps/labrinth/**' - 'apps/labrinth/**'
- 'packages/**'
- '!packages/api-client/**'
- '!packages/app-lib/**'
- Cargo.toml - Cargo.toml
- Cargo.lock - Cargo.lock
merge_group: merge_group:
@@ -69,7 +76,8 @@ jobs:
echo "skip=false" >> $GITHUB_OUTPUT echo "skip=false" >> $GITHUB_OUTPUT
fi fi
docker: build:
name: Build Labrinth
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }} runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
needs: [skip-if-clean] needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }} if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -120,42 +128,30 @@ jobs:
cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
- name: Set up Docker Buildx - name: Upload Docker context
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 if: needs.skip-if-clean.outputs.internal == 'true'
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
- name: Generate Docker image metadata
id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
env:
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
# image manifest only. See:
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with: with:
images: ghcr.io/modrinth/labrinth name: labrinth-docker-context
labels: | retention-days: 1
org.opencontainers.image.title=labrinth path: apps/labrinth/docker-stage
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
annotations: |
org.opencontainers.image.title=labrinth
org.opencontainers.image.description=Modrinth API
org.opencontainers.image.licenses=AGPL-3.0-only
- name: Login to GitHub Packages docker-build:
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 needs: [skip-if-clean, build]
with: if: ${{ needs.skip-if-clean.outputs.internal == 'true' }}
registry: ghcr.io uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
username: ${{ github.actor }} with:
password: ${{ secrets.GITHUB_TOKEN }} image-name: labrinth
dockerfile-path: apps/labrinth/Dockerfile
artifacts-name: labrinth-docker-context
- name: Build and push deploy:
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 needs: [skip-if-clean, docker-build]
with: if: ${{ needs.skip-if-clean.outputs.internal == 'true' && github.ref == 'refs/heads/prod' }}
context: ./apps/labrinth/docker-stage uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
file: ./apps/labrinth/Dockerfile secrets:
push: true ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
tags: ${{ steps.docker-meta.outputs.tags }} with:
labels: ${{ steps.docker-meta.outputs.labels }} application-set: labrinth
annotations: ${{ steps.docker-meta.outputs.annotations }} branch: ${{ github.ref == 'refs/heads/prod' && 'main' || 'develop' }}
environment-name: ${{ github.ref == 'refs/heads/prod' && 'production' || 'staging' }}
+20
View File
@@ -0,0 +1,20 @@
name: Slash Command Dispatch
on:
issue_comment:
types: [created]
permissions: {}
jobs:
dispatch-command:
if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
runs-on: namespace-profile-tiny-arm64
steps:
- name: Slash Command Dispatch
uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2
with:
token: ${{ secrets.SLASH_CMD_GH_TOKEN }}
issue-type: pull-request
commands: |
deploy
-2
View File
@@ -92,7 +92,6 @@ jobs:
run: corepack enable run: corepack enable
- name: Set up caches - name: Set up caches
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with: with:
cache: | cache: |
@@ -100,7 +99,6 @@ jobs:
pnpm pnpm
- name: Configure sccache - name: Configure sccache
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV" run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
- name: Generate tauri-dev.conf.json - name: Generate tauri-dev.conf.json
+9 -2
View File
@@ -59,14 +59,21 @@ apps/frontend/src/generated
.turbo .turbo
target target
generated generated
!apps/app-frontend/src/generated/
!apps/app-frontend/src/generated/app-events/
!apps/app-frontend/src/generated/app-events/*.ts
!apps/app-frontend/src/generated/app-events/README.md
!apps/app-frontend/src/generated/app-events/postcard/
!apps/app-frontend/src/generated/app-events/postcard/index.d.ts
!apps/app-frontend/src/generated/app-events/postcard/index.js
!apps/app-frontend/src/generated/app-events/postcard/package.json
.env .env
# app testing dir # app testing dir
app-playground-data/* app-playground-data/*
.astro .astro
.claude/* .claude/
!.claude/skills/
.letta .letta
# labrinth demo fixtures # labrinth demo fixtures
+11
View File
@@ -17,6 +17,17 @@
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-maxmind/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/modrinth-maxmind/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-util/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/modrinth-util/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/muralpay/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/muralpay/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/async-minecraft-ping/examples" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/async-minecraft-ping/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/async-minecraft-ping/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/labrinth-derive/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-content-management/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-content-management/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/neverbounce/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/serde-binhum/src" isTestSource="false" />
<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" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
+23
View File
@@ -0,0 +1,23 @@
merge_queue:
mode: parallel
scopes:
source:
files:
frontend:
include:
- apps/frontend/**/*
- packages/ui/**/*
- packages/utils/**/*
- packages/assets/**/*
- "**/wrangler.jsonc"
- "**/pnpm-*.yaml"
backend:
include:
- apps/labrinth/**
- Cargo.toml
app:
include:
- apps/app/**
- apps/app-frontend/**
- packages/app-lib/**
- packages/app-macros/**
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+93
View File
@@ -0,0 +1,93 @@
# Modrinth Monorepo
This is the Modrinth monorepo — it contains all Modrinth projects, both frontend and backend. When entering a project, either to edit or analyse, you should read its AGENTS.md.
## Architecture
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
- **Indentation:** Use TAB everywhere, never spaces
### Apps (`apps/`)
| App | Description |
| ----------------- | ------------------------------ |
| `frontend` | Main Modrinth website (Nuxt 3) |
| `app-frontend` | Desktop/app frontend (Vue 3) |
| `app` | Desktop/app shell (Tauri) |
| `app-playground` | Testing playground for app |
| `labrinth` | Backend API service |
| `daedalus_client` | Daedalus client implementation |
| `docs` | Documentation site (Astro) |
### Packages (`packages/`)
| Package | Description |
| ------------------ | ----------------------------------------------------- |
| `ui` | Shared Vue component library (`@modrinth/ui`) |
| `assets` | Styling and auto-generated icons (`@modrinth/assets`) |
| `api-client` | API client for Nuxt, Tauri, and Node/browser |
| `app-lib` | Shared app library |
| `blog` | Blog system and changelog data |
| `utils` | Shared utility functions (mostly deprecated) |
| `moderation` | Moderation utilities |
| `daedalus` | Daedalus protocol |
| `tooling-config` | ESLint, Prettier, TypeScript configs |
| `ariadne` | Analytics library |
| `modrinth-log` | Logging utilities |
| `modrinth-maxmind` | MaxMind GeoIP |
| `modrinth-util` | General utilities |
| `muralpay` | Payment processing |
| `path-util` | Path utilities |
| `sqlx-tracing` | SQLx query tracing |
## Pre-PR Commands
Run these from the **root** folder before opening a pull request - do not run these after each prompt the user gives you, only run when asked, ask the user a question if they want to run it if the user indicates that they are about to create a pull request.
- **Website:** `pnpm prepr:frontend:web`
- **App frontend:** `pnpm prepr:frontend:app`
- **Frontend libs:** `pnpm prepr:frontend:lib`
- **All frontend (app+web):** `pnpm prepr`
- **Labrinth (backend):** See `apps/labrinth/AGENTS.md`
The website and app `prepr` commands
## Dev Commands
- **Website:** `pnpm web:dev` (copy `.env` template in `apps/frontend/` first)
- **App:** `pnpm app:dev` (copy `.env` template in `packages/app-lib/` first)
- **Storybook (packages/ui):** `pnpm storybook`
## Project-Specific Instructions
Each project may have its own file with detailed instructions:
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/frontend/AGENTS.md`](apps/frontend/AGENTS.md) - Frontend Website
## Code Guidelines
### Comments
- DO NOT use "heading" comments like: `=== Helper methods ===`.
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
## Bash Guidelines
### Output handling
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
- NEVER use `| head -n X` or `| tail -n X` to truncate output
- IMPORTANT: Run commands directly without pipes when possible
- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
- ALWAYS read the full output — never pipe through filters
### General
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
- Types in `@modrinth/utils` are considered highly outdated, if a component needs them, check if you can switch said component to use types from `packages/api-client`
- When provided problems, do not say "I didn't introduce these problems" (shifting the blame/effort) - just fix them.
## Standards
Standards available at the @standards/ folder.
-110
View File
@@ -1,110 +0,0 @@
# Modrinth Monorepo
This is the Modrinth monorepo — it contains all Modrinth projects, both frontend and backend. When entering a project, either to edit or analyse, you should read it's CLAUDE.md.
## Architecture
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
- **Indentation:** Use TAB everywhere, never spaces
### Apps (`apps/`)
| App | Description |
| ----------------- | ------------------------------ |
| `frontend` | Main Modrinth website (Nuxt 3) |
| `app-frontend` | Desktop/app frontend (Vue 3) |
| `app` | Desktop/app shell (Tauri) |
| `app-playground` | Testing playground for app |
| `labrinth` | Backend API service |
| `daedalus_client` | Daedalus client implementation |
| `docs` | Documentation site (Astro) |
### Packages (`packages/`)
| Package | Description |
| ------------------ | ----------------------------------------------------- |
| `ui` | Shared Vue component library (`@modrinth/ui`) |
| `assets` | Styling and auto-generated icons (`@modrinth/assets`) |
| `api-client` | API client for Nuxt, Tauri, and Node/browser |
| `app-lib` | Shared app library |
| `blog` | Blog system and changelog data |
| `utils` | Shared utility functions (mostly deprecated) |
| `moderation` | Moderation utilities |
| `daedalus` | Daedalus protocol |
| `tooling-config` | ESLint, Prettier, TypeScript configs |
| `ariadne` | Analytics library |
| `modrinth-log` | Logging utilities |
| `modrinth-maxmind` | MaxMind GeoIP |
| `modrinth-util` | General utilities |
| `muralpay` | Payment processing |
| `path-util` | Path utilities |
| `sqlx-tracing` | SQLx query tracing |
## Pre-PR Commands
Run these from the **root** folder before opening a pull request - do not run these after each prompt the user gives you, only run when asked, ask the user a question if they want to run it if the user indicates that they are about to create a pull request.
- **Website:** `pnpm prepr:frontend:web`
- **App frontend:** `pnpm prepr:frontend:app`
- **Frontend libs:** `pnpm prepr:frontend:lib`
- **All frontend (app+web):** `pnpm prepr`
- **Labrinth (backend):** See `apps/labrinth/AGENTS.md`
The website and app `prepr` commands
## Dev Commands
- **Website:** `pnpm web:dev` (copy `.env` template in `apps/frontend/` first)
- **App:** `pnpm app:dev` (copy `.env` template in `packages/app-lib/` first)
- **Storybook (packages/ui):** `pnpm storybook`
## Project-Specific Instructions
Each project may have its own file with detailed instructions:
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
## Code Guidelines
### Comments
- DO NOT use "heading" comments like: `=== Helper methods ===`.
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
## Bash Guidelines
### Output handling
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
- NEVER use `| head -n X` or `| tail -n X` to truncate output
- IMPORTANT: Run commands directly without pipes when possible
- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
- ALWAYS read the full output — never pipe through filters
### General
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
- Types in `@modrinth/utils` are considered highly outdated, if a component needs them, check if you can switch said component to use types from `packages/api-client`
- When provided problems, do not say "I didn't introduce these problems" (shifting the blame/effort) - just fix them.
## Edit Tool - Whitespace Handling (CLAUDE ONLY)
The Read tool uses `→` to mark where line numbers end and file content begins.
**Rule:** Copy the EXACT whitespace that appears after the `→` marker.
- Whatever appears between `→` and the code text is what's actually in the file
- That whitespace must be used EXACTLY in Edit tool's old_string
- Don't count arrows, don't interpret - just copy what's after the `→`
**Example:**
14→ private byte tag;
For Edit, use: ` private byte tag;` (copy everything after →, including the two tabs)
**If Edit fails:** Stop and explain the problem. Do not attempt sed/awk/bash workarounds.
**IMPORTANT**: Trust the Read tool output. Copy what's after `→` into Edit immediately. DO NOT verify with sed/od/grep first - that's wasting time and the instructions already tell you to stop if Edit fails, not to pre-verify.
## Standards
Standards available at the @standards/ folder.
Generated
+198 -84
View File
@@ -2116,7 +2116,7 @@ checksum = "ff6669899e23cb87b43daf7996f0ea3b9c07d0fb933d745bb7b815b052515ae3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde_derive_internals", "serde_derive_internals 0.29.1",
"syn 2.0.106", "syn 2.0.106",
] ]
@@ -2333,18 +2333,18 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.8.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.10.0" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
@@ -4000,6 +4000,28 @@ dependencies = [
"x11", "x11",
] ]
[[package]]
name = "genco"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ab846431e5d637791b3279e7242fe2b21e11c3d8b4cf6a99f645c5f16ba7c0"
dependencies = [
"genco-macros",
"relative-path",
"smallvec",
]
[[package]]
name = "genco-macros"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42a1fe5a699c7f1d36ea6e04ed680a5c787cabff4b610ae3b8954ea3bcefec1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.9" version = "0.14.9"
@@ -5217,15 +5239,6 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "iso8601"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46"
dependencies = [
"nom 8.0.0",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.12.1" version = "0.12.1"
@@ -5365,6 +5378,16 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "json5"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c"
dependencies = [
"serde",
"ucd-trie",
]
[[package]] [[package]]
name = "jsonptr" name = "jsonptr"
version = "0.6.3" version = "0.6.3"
@@ -5385,19 +5408,6 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"ring",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "keyboard-types" name = "keyboard-types"
version = "0.7.0" version = "0.7.0"
@@ -5491,7 +5501,6 @@ dependencies = [
"json-patch 4.1.0", "json-patch 4.1.0",
"labrinth", "labrinth",
"lettre", "lettre",
"meilisearch-sdk",
"modrinth-content-management", "modrinth-content-management",
"modrinth-util", "modrinth-util",
"muralpay", "muralpay",
@@ -5515,6 +5524,7 @@ dependencies = [
"scalar_api_reference", "scalar_api_reference",
"sentry", "sentry",
"serde", "serde",
"serde-binhum",
"serde_json", "serde_json",
"serde_with", "serde_with",
"sha1 0.10.6", "sha1 0.10.6",
@@ -5954,49 +5964,6 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
[[package]]
name = "meilisearch-index-setting-macro"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e36e5cad3754ed329feb201c24f26c0694442bef50a90dbcff0ba6d12b5ca133"
dependencies = [
"convert_case 0.8.0",
"proc-macro2",
"quote",
"structmeta",
"syn 2.0.106",
]
[[package]]
name = "meilisearch-sdk"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f620c9ed9b790cf292b66c8dab16f0e4aafb377583ec0e589da7c3ee81ff2038"
dependencies = [
"async-trait",
"bytes",
"either",
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"iso8601",
"jsonwebtoken",
"log",
"meilisearch-index-setting-macro",
"pin-project-lite",
"reqwest 0.12.24",
"serde",
"serde_json",
"thiserror 2.0.17",
"time",
"tokio",
"uuid 1.23.3",
"wasm-bindgen-futures",
"web-sys",
"yaup",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.6" version = "2.7.6"
@@ -7573,6 +7540,45 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "postcard-bindgen"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e709af61573ae2563a00e760ff7db47f5c70b9d96c6dcb28993526fb8479f68"
dependencies = [
"postcard-bindgen-core",
"postcard-bindgen-derive",
]
[[package]]
name = "postcard-bindgen-core"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f19cebcb145fdccb24b63ad410785aa7c2ff9364551646ca7a927422d2d0386"
dependencies = [
"convert_case 0.11.0",
"genco",
"tree-ds",
]
[[package]]
name = "postcard-bindgen-derive"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0372f55dd7c15d73f7b0f7ca507ac3db5653f346b2b44a36537e2ad39f55441b"
dependencies = [
"convert_case 0.11.0",
"genco",
"postcard-bindgen-core",
"proc-macro2",
"quote",
"regex",
"regex-macro",
"serde",
"serde_derive_internals 0.30.0",
"syn 3.0.3",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.3" version = "0.1.3"
@@ -8455,12 +8461,27 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da"
[[package]]
name = "regex-macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d306632607af6ec61c0b117971d57a96381b6317cf18ae419b5558048fe016e"
dependencies = [
"regex",
]
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.8" version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "relative-path"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]] [[package]]
name = "rend" name = "rend"
version = "0.4.2" version = "0.4.2"
@@ -9126,7 +9147,7 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde_derive_internals", "serde_derive_internals 0.29.1",
"syn 2.0.106", "syn 2.0.106",
] ]
@@ -9369,6 +9390,16 @@ dependencies = [
"uuid 1.23.3", "uuid 1.23.3",
] ]
[[package]]
name = "sequential_gen"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d630b1418311e028ceb45e1e567845501a9d9c770a258b80d2edc025ffad17c"
dependencies = [
"lazy_static",
"uuid 1.23.3",
]
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -9379,6 +9410,16 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-binhum"
version = "0.1.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]] [[package]]
name = "serde-untagged" name = "serde-untagged"
version = "0.1.9" version = "0.1.9"
@@ -9464,6 +9505,17 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "serde_ini" name = "serde_ini"
version = "0.2.0" version = "0.2.0"
@@ -9904,6 +9956,9 @@ name = "spin"
version = "0.10.0" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "spinning_top" name = "spinning_top"
@@ -10331,6 +10386,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@@ -10944,6 +11010,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "testcontainers" name = "testcontainers"
version = "0.25.2" version = "0.25.2"
@@ -11023,6 +11098,7 @@ dependencies = [
"image", "image",
"indicatif", "indicatif",
"itertools 0.14.0", "itertools 0.14.0",
"json5",
"modrinth-content-management", "modrinth-content-management",
"notify", "notify",
"notify-debouncer-mini", "notify-debouncer-mini",
@@ -11032,6 +11108,8 @@ dependencies = [
"path-util", "path-util",
"phf 0.13.1", "phf 0.13.1",
"png 0.18.0", "png 0.18.0",
"postcard",
"postcard-bindgen",
"quartz_nbt", "quartz_nbt",
"quick-xml 0.38.3", "quick-xml 0.38.3",
"rand 0.8.5", "rand 0.8.5",
@@ -11039,6 +11117,7 @@ dependencies = [
"reqwest 0.12.24", "reqwest 0.12.24",
"rgb", "rgb",
"serde", "serde",
"serde-binhum",
"serde_ini", "serde_ini",
"serde_json", "serde_json",
"serde_with", "serde_with",
@@ -11052,9 +11131,11 @@ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"tokio-util", "tokio-util",
"toml 0.9.8",
"tracing", "tracing",
"tracing-error", "tracing-error",
"tracing-subscriber", "tracing-subscriber",
"ts-rs",
"url", "url",
"urlencoding", "urlencoding",
"uuid 1.23.3", "uuid 1.23.3",
@@ -11731,12 +11812,49 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "tree-ds"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710c388a2bb52d1a7186ae4aa2e92c6fae0606426bf4bdfb4106fa1e8e5c602e"
dependencies = [
"lazy_static",
"sequential_gen",
"serde",
"spin 0.10.0",
"thiserror 2.0.17",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"chrono",
"thiserror 2.0.17",
"ts-rs-macros",
"uuid 1.23.3",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"termcolor",
]
[[package]] [[package]]
name = "tungstenite" name = "tungstenite"
version = "0.27.0" version = "0.27.0"
@@ -11774,6 +11892,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]] [[package]]
name = "uds_windows" name = "uds_windows"
version = "1.1.0" version = "1.1.0"
@@ -13443,12 +13567,13 @@ dependencies = [
"chrono", "chrono",
"dashmap", "dashmap",
"deadpool-redis", "deadpool-redis",
"eyre",
"futures", "futures",
"lz4_flex", "lz4_flex",
"postcard",
"prometheus", "prometheus",
"redis", "redis",
"serde", "serde",
"serde_json",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"tracing", "tracing",
@@ -13487,17 +13612,6 @@ dependencies = [
"xml-rs", "xml-rs",
] ]
[[package]]
name = "yaup"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0144f1a16a199846cb21024da74edd930b43443463292f536b7110b4855b5c6"
dependencies = [
"form_urlencoded",
"serde",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.0" version = "0.8.0"
+6 -1
View File
@@ -15,6 +15,7 @@ members = [
"packages/modrinth-util", "packages/modrinth-util",
"packages/neverbounce", "packages/neverbounce",
"packages/path-util", "packages/path-util",
"packages/serde-binhum",
"packages/xredis", "packages/xredis",
] ]
@@ -112,6 +113,7 @@ indicatif = "0.18.0"
itertools = "0.14.0" itertools = "0.14.0"
jemalloc_pprof = "0.8.1" jemalloc_pprof = "0.8.1"
json-patch = { version = "4.1.0", default-features = false } json-patch = { version = "4.1.0", default-features = false }
json5 = "1.3.1"
lettre = { version = "0.11.19", default-features = false, features = [ lettre = { version = "0.11.19", default-features = false, features = [
"aws-lc-rs", "aws-lc-rs",
"builder", "builder",
@@ -130,7 +132,6 @@ lz4_flex = { version = "0.11.5", default-features = false, features = [
"std", "std",
] } ] }
maxminddb = "0.26.0" maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.30.0", default-features = false }
modrinth-content-management = { path = "packages/modrinth-content-management" } modrinth-content-management = { path = "packages/modrinth-content-management" }
modrinth-log = { path = "packages/modrinth-log" } modrinth-log = { path = "packages/modrinth-log" }
modrinth-util = { path = "packages/modrinth-util" } modrinth-util = { path = "packages/modrinth-util" }
@@ -148,6 +149,7 @@ path-util = { path = "packages/path-util" }
phf = { version = "0.13.1", features = ["macros"] } phf = { version = "0.13.1", features = ["macros"] }
png = "0.18.0" png = "0.18.0"
postcard = { version = "1.1.3", default-features = false, features = ["alloc"] } postcard = { version = "1.1.3", default-features = false, features = ["alloc"] }
postcard-bindgen = "0.8.0"
proc-macro2 = { version = "1.0" } proc-macro2 = { version = "1.0" }
prometheus = "0.14.0" prometheus = "0.14.0"
quartz_nbt = "0.2.9" quartz_nbt = "0.2.9"
@@ -183,6 +185,7 @@ sentry = { version = "0.45.0", default-features = false, features = [
"rustls", "rustls",
] } ] }
serde = "1.0.228" serde = "1.0.228"
serde-binhum = { path = "packages/serde-binhum" }
serde_bytes = "0.11.19" serde_bytes = "0.11.19"
serde_cbor = "0.11.2" serde_cbor = "0.11.2"
serde_ini = "0.2.0" serde_ini = "0.2.0"
@@ -223,12 +226,14 @@ tikv-jemallocator = "0.6.0"
tokio = "1.47.1" tokio = "1.47.1"
tokio-stream = "0.1.17" tokio-stream = "0.1.17"
tokio-util = "0.7.16" tokio-util = "0.7.16"
toml = "0.9.8"
totp-rs = "5.7.0" totp-rs = "5.7.0"
tracing = "0.1.41" tracing = "0.1.41"
tracing-actix-web = { version = "0.7.19", default-features = false } tracing-actix-web = { version = "0.7.19", default-features = false }
tracing-ecs = "0.5.0" tracing-ecs = "0.5.0"
tracing-error = "0.2.1" tracing-error = "0.2.1"
tracing-subscriber = "0.3.20" tracing-subscriber = "0.3.20"
ts-rs = "12.0.1"
typed-path = "0.12.0" typed-path = "0.12.0"
url = "2.5.7" url = "2.5.7"
urlencoding = "2.1.3" urlencoding = "2.1.3"
+1 -1
View File
@@ -9,7 +9,7 @@
## Modrinth Monorepo ## Modrinth Monorepo
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge%3Fformat%3Dhuman&logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors! Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines of code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge%3Fformat%3Dhuman&logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app). If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
+4
View File
@@ -24,3 +24,7 @@ gam = "gam"
consts = "consts" consts = "consts"
# short for "Copy" # short for "Copy"
Cpy = "Cpy" Cpy = "Cpy"
[default.extend-identifiers]
# Constant from the `zip` crate
ZIP64_BYTES_THR = "ZIP64_BYTES_THR"
+4
View File
@@ -2,3 +2,7 @@
*.gltf *.gltf
src/locales/ src/locales/
src/assets/**/*.svg src/assets/**/*.svg
# Generated app-event bindings
src/generated/app-events/*.ts
src/generated/app-events/postcard/**
+6 -1
View File
@@ -1,2 +1,7 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs' import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config
export default config.append([
{
ignores: ['src/generated/app-events/*.ts', 'src/generated/app-events/postcard/**'],
},
])
+146 -82
View File
@@ -29,24 +29,25 @@ import {
import { import {
Admonition, Admonition,
Avatar, Avatar,
ButtonStyled, ButtonLink,
commonMessages, commonMessages,
ContentInstallModal, ContentInstallModal,
ContentUpdaterModal, ContentUpdaterModal,
CreationFlowModal, CreationFlowModal,
defineMessages, defineMessages,
I18nDebugPanel, I18nDebugPanel,
IconButton,
IntlFormatted, IntlFormatted,
LoadingBar, LoadingBar,
NewsArticleCard, NewsArticleCard,
NotificationPanel, NotificationPanel,
OverflowMenu,
PopupNotificationPanel, PopupNotificationPanel,
provideModalBehavior, provideModalBehavior,
provideModrinthClient, provideModrinthClient,
provideNotificationManager, provideNotificationManager,
providePageContext, providePageContext,
providePopupNotificationManager, providePopupNotificationManager,
TeleportOverflowMenu,
TextLogo, TextLogo,
useDebugLogger, useDebugLogger,
useFormatBytes, useFormatBytes,
@@ -70,6 +71,7 @@ import AppActionBar from '@/components/ui/AppActionBar.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue' import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue' import ErrorModal from '@/components/ui/ErrorModal.vue'
import FriendsList from '@/components/ui/friends/FriendsList.vue' 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 AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue' import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue' import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
@@ -88,19 +90,19 @@ import SplashScreen from '@/components/ui/SplashScreen.vue'
import SurveyPopup from '@/components/ui/SurveyPopup.vue' import SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue' import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js' import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useAppEvent } from '@/composables/use-app-event'
import { config } from '@/config' import { config } from '@/config'
import { import {
ads_consent_listener,
hide_ads_window, hide_ads_window,
init_ads_window, init_ads_window,
perform_ads_consent_action, perform_ads_consent_action,
release_ads_window_hold,
should_show_ads_consent_popup, should_show_ads_consent_popup,
show_ads_window, take_ads_window_hold,
} from '@/helpers/ads.js' } from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics' import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js' import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js' import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance' import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts' import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
@@ -120,6 +122,7 @@ import {
} from '@/helpers/utils.js' } from '@/helpers/utils.js'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts' import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n from '@/i18n.config' import i18n from '@/i18n.config'
import { instanceKeys } from '@/pages/instance/query-options'
import { import {
appUpdateState, appUpdateState,
downloadAvailableAppUpdate, downloadAvailableAppUpdate,
@@ -138,6 +141,7 @@ import {
} from '@/providers/download-progress.ts' } from '@/providers/download-progress.ts'
import { createServerInstall, provideServerInstall } from '@/providers/server-install' import { createServerInstall, provideServerInstall } from '@/providers/server-install'
import { setupProviders } from '@/providers/setup' import { setupProviders } from '@/providers/setup'
import { setupAppEventsProvider } from '@/providers/setup/app-events'
import { setupAuthProvider } from '@/providers/setup/auth' import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state' import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { useError } from '@/store/error.js' import { useError } from '@/store/error.js'
@@ -153,6 +157,7 @@ import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming() const themeStore = useTheming()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider()
const breadcrumbManager = createBreadcrumbManager() const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager) provideBreadcrumbManager(breadcrumbManager)
const canNavigateBack = ref(false) const canNavigateBack = ref(false)
@@ -164,6 +169,25 @@ function updateHistoryNavigationState() {
canNavigateForward.value = historyState?.forward != null canNavigateForward.value = historyState?.forward != null
} }
let fullscreenAdsWindowHold = false
async function handleFullscreenChange() {
const fullscreen = document.fullscreenElement !== null
if (fullscreen === fullscreenAdsWindowHold) return
fullscreenAdsWindowHold = fullscreen
try {
if (fullscreen) {
await take_ads_window_hold()
} else {
await release_ads_window_hold()
}
} catch (error) {
fullscreenAdsWindowHold = !fullscreen
handleError(error)
}
}
updateHistoryNavigationState() updateHistoryNavigationState()
const APP_LEFT_NAV_WIDTH = '4rem' const APP_LEFT_NAV_WIDTH = '4rem'
@@ -173,14 +197,26 @@ const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref() const credentials = ref()
let credentialsRefreshId = 0 let credentialsRefreshId = 0
const sidebarToggled = ref(true) const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => { watch(
sidebarToggled.value = !themeStore.toggleSidebar () => themeStore.toggleSidebar,
}) (toggleSidebar) => {
sidebarToggled.value = !toggleSidebar
},
)
const forceSidebar = computed( const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'), () =>
route.path.startsWith('/browse') ||
route.path.startsWith('/project') ||
route.path.startsWith('/user'),
) )
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value) const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting')) const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const hostingUpdateRequired = computed(
() =>
hostingRouteActive.value &&
!!appUpdateState.availableUpdate.value &&
appUpdateState.updatesEnabled.value,
)
const prideFundraiserEnabled = computed( const prideFundraiserEnabled = computed(
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE, () => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
) )
@@ -191,7 +227,9 @@ const hostingIntercomIdentityKey = computed(() => {
return `${userId}:${serverId ?? 'hosting'}` return `${userId}:${serverId ?? 'hosting'}`
}) })
const hostingIntercom = useHostingIntercom({ const hostingIntercom = useHostingIntercom({
enabled: computed(() => hostingRouteActive.value && !!credentials.value?.session), enabled: computed(
() => hostingRouteActive.value && !hostingUpdateRequired.value && !!credentials.value?.session,
),
appId: 'ykeritl9', appId: 'ykeritl9',
fetchToken: fetchIntercomToken, fetchToken: fetchIntercomToken,
identityKey: hostingIntercomIdentityKey, identityKey: hostingIntercomIdentityKey,
@@ -206,11 +244,22 @@ const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager) provideNotificationManager(notificationManager)
const { handleError, addNotification } = notificationManager const { handleError, addNotification } = notificationManager
useAppEvent(
'warning',
(event) =>
addNotification({
title: 'Warning',
text: event.message,
type: 'warning',
}),
appEvents,
)
const popupNotificationManager = new AppPopupNotificationManager() const popupNotificationManager = new AppPopupNotificationManager()
providePopupNotificationManager(popupNotificationManager) providePopupNotificationManager(popupNotificationManager)
const { addPopupNotification } = popupNotificationManager const { addPopupNotification } = popupNotificationManager
let adsConsentPopupId = null let adsConsentPopupId = null
let unlistenAdsConsent useAppEvent('ads_consent_required', handleAdsConsentRequired, appEvents)
const appVersion = getVersion() const appVersion = getVersion()
const tauriApiClient = new TauriModrinthClient({ const tauriApiClient = new TauriModrinthClient({
@@ -242,7 +291,7 @@ const { data: authenticatedModrinthUser } = useQuery({
retry: false, retry: false,
}) })
useQuery({ useQuery({
queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]), queryKey: computed(() => instanceKeys.sharedEligibility(credentials.value?.user?.id)),
queryFn: can_current_user_use_shared_instances, queryFn: can_current_user_use_shared_instances,
enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id, enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id,
retry: false, retry: false,
@@ -279,8 +328,8 @@ providePageContext({
}) })
provideModalBehavior({ provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering), noblur: computed(() => !themeStore.advancedRendering),
onShow: () => hide_ads_window(), onShow: () => take_ads_window_hold(),
onHide: () => show_ads_window(), onHide: () => release_ads_window_hold(),
}) })
const { const {
@@ -295,7 +344,7 @@ const {
setModpackAlreadyInstalledModal, setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway, handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance, handleModpackDuplicateGoToInstance,
} = setupProviders(notificationManager, popupNotificationManager) } = setupProviders(tauriApiClient, notificationManager, popupNotificationManager)
const news = ref([]) const news = ref([])
const displayedServerInviteNotifications = new Set() const displayedServerInviteNotifications = new Set()
@@ -347,7 +396,6 @@ const authUnreachable = computed(() => {
onMounted(async () => { onMounted(async () => {
await useCheckDisableMouseover() await useCheckDisableMouseover()
try { try {
unlistenAdsConsent = await ads_consent_listener(handleAdsConsentRequired)
handleAdsConsentRequired(await should_show_ads_consent_popup()) handleAdsConsentRequired(await should_show_ads_consent_popup())
} catch (error) { } catch (error) {
handleError(error) handleError(error)
@@ -355,6 +403,7 @@ onMounted(async () => {
document.querySelector('body').addEventListener('click', handleClick) document.querySelector('body').addEventListener('click', handleClick)
document.querySelector('body').addEventListener('auxclick', handleAuxClick) document.querySelector('body').addEventListener('auxclick', handleAuxClick)
document.addEventListener('fullscreenchange', handleFullscreenChange)
checkUpdates() checkUpdates()
}) })
@@ -362,10 +411,13 @@ onMounted(async () => {
onUnmounted(async () => { onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick) document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick) document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
unsubscribeSidebarToggle() document.removeEventListener('fullscreenchange', handleFullscreenChange)
clearDelayedUpdatePopup() clearDelayedUpdatePopup()
await unlistenAdsConsent?.() if (fullscreenAdsWindowHold) {
fullscreenAdsWindowHold = false
await release_ads_window_hold().catch(handleError)
}
await unlistenUpdateDownload?.() await unlistenUpdateDownload?.()
}) })
@@ -574,14 +626,6 @@ async function setupApp() {
document.getElementsByTagName('html')[0].classList.add('windows') document.getElementsByTagName('html')[0].classList.add('windows')
} }
await warning_listener((e) =>
addNotification({
title: 'Warning',
text: e.message,
type: 'warning',
}),
)
fetch(`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`) fetch(`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`)
.then((response) => response.json()) .then((response) => response.json())
.then((res) => { .then((res) => {
@@ -630,7 +674,7 @@ async function setupApp() {
} }
const stateFailed = ref(false) const stateFailed = ref(false)
initialize_state() initialize_state(appEventChannel)
.then(() => { .then(() => {
setupApp().catch((err) => { setupApp().catch((err) => {
stateFailed.value = true stateFailed.value = true
@@ -760,7 +804,7 @@ const errorModal = ref()
const minecraftAuthErrorModal = ref() const minecraftAuthErrorModal = ref()
const minecraftRequiredModal = ref() const minecraftRequiredModal = ref()
const contentInstall = createContentInstall({ router, handleError }) const contentInstall = createContentInstall({ router, handleError, appEvents })
provideContentInstall(contentInstall) provideContentInstall(contentInstall)
const { const {
instances: contentInstallInstances, instances: contentInstallInstances,
@@ -793,7 +837,12 @@ const {
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel, handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
} = contentInstall } = contentInstall
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager }) const serverInstall = createServerInstall({
router,
handleError,
popupNotificationManager,
appEvents,
})
provideServerInstall(serverInstall) provideServerInstall(serverInstall)
const { const {
setInstallToPlayModal: setServerInstallToPlayModal, setInstallToPlayModal: setServerInstallToPlayModal,
@@ -969,8 +1018,8 @@ onMounted(() => {
const accounts = ref(null) const accounts = ref(null)
provide('accountsCard', accounts) provide('accountsCard', accounts)
command_listener(handleCommand) useAppEvent('command', handleCommand, appEvents)
notification_listener(handleLiveNotification) useAppEvent('notification', handleLiveNotification, appEvents)
async function markLiveNotificationRead(notification) { async function markLiveNotificationRead(notification) {
try { try {
@@ -1396,6 +1445,7 @@ async function downloadUpdate(versionToDownload) {
handleError(e) handleError(e)
}) })
unlistenUpdateDownload = await subscribeToDownloadProgress( unlistenUpdateDownload = await subscribeToDownloadProgress(
appEvents,
appUpdateDownload, appUpdateDownload,
versionToDownload.version, versionToDownload.version,
) )
@@ -1545,8 +1595,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton <NavButton
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)" v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
to="/browse/modpack" to="/browse/modpack"
:is-primary="() => route.path.startsWith('/browse') && !route.query.i" :is-primary="() => route.path.startsWith('/browse') && !route.query.i && !route.query.sid"
:is-subpage="(route) => route.path.startsWith('/project') && !route.query.i" :is-subpage="
(route) => route.path.startsWith('/project') && !route.query.i && !route.query.sid
"
> >
<CompassIcon /> <CompassIcon />
</NavButton> </NavButton>
@@ -1570,7 +1622,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
v-tooltip.right="formatMessage(messages.modrinthHosting)" v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage" to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'" :is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
:is-subpage="(r) => r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/'" :is-subpage="
(r) =>
(r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/') ||
((r.path.startsWith('/browse') || r.path.startsWith('/project')) && r.query.sid)
"
> >
<ServerStackIcon /> <ServerStackIcon />
</NavButton> </NavButton>
@@ -1591,22 +1647,29 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
<SettingsIcon /> <SettingsIcon />
</NavButton> </NavButton>
<OverflowMenu <TeleportOverflowMenu
v-if="credentials?.user" v-if="credentials?.user"
v-tooltip.right="formatMessage(messages.modrinthAccount)" v-tooltip.right="formatMessage(messages.modrinthAccount)"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer" type="quiet"
size="xl"
label="More options"
:options="[ :options="[
{ {
id: 'view-profile', id: 'view-profile',
label: formatMessage(messages.signedInAs, {
username: credentials.user.username,
}),
action: () => router.push(`/user/${encodeURIComponent(credentials.user.username)}`), action: () => router.push(`/user/${encodeURIComponent(credentials.user.username)}`),
}, },
{ {
id: 'sign-out', id: 'sign-out',
label: formatMessage(commonMessages.signOutButton),
tone: 'red',
action: () => logOut(), action: () => logOut(),
color: 'danger',
}, },
]" ]"
placement="right-end" placement="right-end"
:distance="4"
> >
<Avatar :src="credentials?.user?.avatar_url" alt="" size="32px" circle /> <Avatar :src="credentials?.user?.avatar_url" alt="" size="32px" circle />
<template #view-profile> <template #view-profile>
@@ -1629,7 +1692,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<LogOutIcon /> <LogOutIcon />
{{ formatMessage(commonMessages.signOutButton) }} {{ formatMessage(commonMessages.signOutButton) }}
</template> </template>
</OverflowMenu> </TeleportOverflowMenu>
<NavButton <NavButton
v-else v-else
v-tooltip.right="formatMessage(messages.signInToModrinthAccount)" v-tooltip.right="formatMessage(messages.signInToModrinthAccount)"
@@ -1642,49 +1705,44 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2"> <div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" /> <TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2"> <div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<ButtonStyled type="outlined" circular> <IconButton
<button type="outlined"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100" label="Go back"
:disabled="!canNavigateBack" class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
aria-label="Go back" :disabled="!canNavigateBack"
@click="router.back()" @click="router.back()"
> >
<ChevronLeftIcon <ChevronLeftIcon
class="!size-4 !text-primary" class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateBack }" :class="{ 'opacity-20': !canNavigateBack }"
/> />
</button> </IconButton>
</ButtonStyled> <IconButton
<ButtonStyled type="outlined" circular> type="outlined"
<button label="Go forward"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100" class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateForward" :disabled="!canNavigateForward"
aria-label="Go forward" @click="router.forward()"
@click="router.forward()" >
> <ChevronRightIcon
<ChevronRightIcon class="!size-4 !text-primary"
class="!size-4 !text-primary" :class="{ 'opacity-20': !canNavigateForward }"
:class="{ 'opacity-20': !canNavigateForward }" />
/> </IconButton>
</button>
</ButtonStyled>
</div> </div>
<Breadcrumbs /> <Breadcrumbs />
</div> </div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center"> <section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<ButtonStyled <IconButton
v-if="!forceSidebar && themeStore.toggleSidebar" v-if="!forceSidebar && themeStore.toggleSidebar"
:type="sidebarToggled ? 'standard' : 'transparent'" :type="sidebarToggled ? 'base' : 'quiet'"
circular label="Next image"
class="mr-3 transition-transform"
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
> >
<button <RightArrowIcon />
class="mr-3 transition-transform" </IconButton>
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
>
<RightArrowIcon />
</button>
</ButtonStyled>
<div class="flex mr-3"> <div class="flex mr-3">
<Suspense> <Suspense>
<AppActionBar /> <AppActionBar />
@@ -1746,7 +1804,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
{{ formatMessage(messages.authUnreachableBody) }} {{ formatMessage(messages.authUnreachableBody) }}
</Admonition> </Admonition>
<RouterView v-slot="{ Component }"> <HostingUpdateRequired v-if="hostingUpdateRequired" />
<RouterView v-else v-slot="{ Component }">
<template v-if="Component"> <template v-if="Component">
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve"> <Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<component :is="Component"></component> <component :is="Component"></component>
@@ -1793,12 +1852,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:key="`news-${index}`" :key="`news-${index}`"
:article="item" :article="item"
/> />
<ButtonStyled color="brand" size="large"> <ButtonLink
<a href="https://modrinth.com/news" target="_blank" class="my-4"> type="colored"
<NewspaperIcon /> color="brand"
{{ formatMessage(messages.viewAllNews) }} size="xl"
</a> href="https://modrinth.com/news"
</ButtonStyled> target="_blank"
class="my-4"
>
<NewspaperIcon />
{{ formatMessage(messages.viewAllNews) }}
</ButtonLink>
</div> </div>
</div> </div>
</div> </div>
@@ -4,13 +4,11 @@
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2" class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
> >
<span>{{ formatMessage(messages.notSignedIn) }}</span> <span>{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled color="brand"> <Button type="colored" color="brand" :disabled="loginDisabled" @click="login()">
<button color="primary" :disabled="loginDisabled" @click="login()"> <LogInIcon v-if="!loginDisabled" />
<LogInIcon v-if="!loginDisabled" /> <SpinnerIcon v-else class="animate-spin" />
<SpinnerIcon v-else class="animate-spin" /> {{ formatMessage(messages.signInToMinecraft) }}
{{ formatMessage(messages.signInToMinecraft) }} </Button>
</button>
</ButtonStyled>
</div> </div>
<Accordion <Accordion
v-else v-else
@@ -60,24 +58,28 @@
{{ account.profile.name }} {{ account.profile.name }}
</p> </p>
</button> </button>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background"> <IconButton
<button v-tooltip="formatMessage(messages.removeAccount)"
v-tooltip="formatMessage(messages.removeAccount)" type="quiet"
class="mr-2" color="red"
@click="logout(account.profile.id)" :label="formatMessage(messages.removeAccount)"
> class="mr-2 !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)] hover:!bg-red focus-visible:!bg-red hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
<TrashIcon /> @click="logout(account.profile.id)"
</button> >
</ButtonStyled> <TrashIcon />
</IconButton>
</div> </div>
</template> </template>
<div class="flex flex-col gap-2 px-2 pt-2"> <div class="flex flex-col gap-2 px-2 pt-2">
<ButtonStyled v-if="accounts.length > 0" class="w-full"> <Button
<button :disabled="loginDisabled" @click="login()"> v-if="accounts.length > 0"
<PlusIcon /> class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
{{ formatMessage(messages.addAccount) }} :disabled="loginDisabled"
</button> @click="login()"
</ButtonStyled> >
<PlusIcon />
{{ formatMessage(messages.addAccount) }}
</Button>
</div> </div>
</div> </div>
</Accordion> </Accordion>
@@ -95,14 +97,16 @@ import {
import { import {
Accordion, Accordion,
Avatar, Avatar,
ButtonStyled, Button,
defineMessages, defineMessages,
IconButton,
injectNotificationManager, injectNotificationManager,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue' import { computed, ref } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { import {
get_default_user, get_default_user,
@@ -111,7 +115,6 @@ import {
set_default_user, set_default_user,
users, users,
} from '@/helpers/auth' } from '@/helpers/auth'
import { process_listener } from '@/helpers/events'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts' import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins' import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins' import { get_available_skins } from '@/helpers/skins'
@@ -248,16 +251,12 @@ async function logout(id: string) {
trackEvent('AccountLogOut') trackEvent('AccountLogOut')
} }
const unlisten = await process_listener(async (e) => { useAppEvent('process', async (e) => {
if (e.event === 'launched') { if (e.event === 'launched') {
await refreshValues() await refreshValues()
} }
}) })
onUnmounted(() => {
unlisten()
})
const messages = defineMessages({ const messages = defineMessages({
notSignedIn: { notSignedIn: {
id: 'minecraft-account.not-signed-in', id: 'minecraft-account.not-signed-in',
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets' import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth/ui' import { Button, injectNotificationManager, TeleportOverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -36,27 +36,26 @@ const handleSearchContent = async () => {
<template> <template>
<div class="joined-buttons"> <div class="joined-buttons">
<ButtonStyled> <Button @click="handleSearchContent">
<button @click="handleSearchContent"> <PlusIcon />
<PlusIcon /> Install content
Install content </Button>
</button> <TeleportOverflowMenu
</ButtonStyled> label="More options"
<ButtonStyled> :options="[
<OverflowMenu {
:options="[ id: 'from_file',
{ label: 'Add from file',
id: 'from_file', action: handleAddContentFromFile,
action: handleAddContentFromFile, },
}, ]"
]" class="!w-auto !px-2.5 !rounded-xl"
> >
<DropdownIcon /> <DropdownIcon />
<template #from_file> <template #from_file>
<FolderOpenIcon /> <FolderOpenIcon />
<span class="no-wrap"> Add from file </span> <span class="no-wrap"> Add from file </span>
</template> </template>
</OverflowMenu> </TeleportOverflowMenu>
</ButtonStyled>
</div> </div>
</template> </template>
@@ -1,15 +1,16 @@
<template> <template>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<ButtonStyled <div v-if="downloadState.total > 0 || hasActiveLoadingBars" class="relative">
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts" <IconButton
color="brand" v-tooltip="downloadToggleLabel"
type="transparent" :color="downloadState.hidden > 0 ? 'brand' : undefined"
circular type="quiet"
> :label="downloadToggleLabel"
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="openDownloadToast()"> @click="toggleDownloadNotifications"
>
<DownloadIcon /> <DownloadIcon />
</button> </IconButton>
</ButtonStyled> </div>
<div v-if="offline" class="flex items-center gap-1"> <div v-if="offline" class="flex items-center gap-1">
<UnplugIcon class="text-secondary" /> <UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span> <span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
@@ -36,17 +37,23 @@
@show="showInstances = true" @show="showInstances = true"
@hide="showInstances = false" @hide="showInstances = false"
> >
<ButtonStyled type="transparent" circular size="small"> <IconButton
<button v-tooltip="
v-tooltip=" showInstances
showInstances ? formatMessage(messages.hideMoreRunningInstances)
? formatMessage(messages.hideMoreRunningInstances) : formatMessage(messages.showMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances) "
" class="!size-6"
> type="quiet"
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" /> size="xs"
</button> :label="
</ButtonStyled> showInstances
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
>
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
</IconButton>
<template #popper> <template #popper>
<div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto"> <div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
<div <div
@@ -125,8 +132,8 @@ import {
TerminalSquareIcon, TerminalSquareIcon,
UnplugIcon, UnplugIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { IconButton } from '@modrinth/ui'
import { import {
ButtonStyled,
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
injectPopupNotificationManager, injectPopupNotificationManager,
@@ -141,8 +148,8 @@ import { useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue' import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications' import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
import { get_many as getInstances } from '@/helpers/instance' import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process' import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state' import type { LoadingBar } from '@/helpers/state'
@@ -212,8 +219,35 @@ const messages = defineMessages({
id: 'app.action-bar.view-active-downloads', id: 'app.action-bar.view-active-downloads',
defaultMessage: 'View active downloads', defaultMessage: 'View active downloads',
}, },
hideDownloads: {
id: 'app.action-bar.hide-downloads',
defaultMessage: 'Hide active downloads',
},
showDownloads: {
id: 'app.action-bar.show-downloads',
defaultMessage: 'Show active downloads',
},
}) })
const downloadState = computed(() => popupNotificationManager.getDownloadState())
const downloadToggleLabel = computed(() =>
formatMessage(
downloadState.value.hidden > 0
? messages.showDownloads
: downloadState.value.total > 0
? messages.hideDownloads
: messages.viewActiveDownloads,
),
)
function toggleDownloadNotifications(): void {
if (downloadState.value.total > 0) {
popupNotificationManager.toggleDownloadNotifications()
} else if (hasActiveLoadingBars.value) {
openDownloadToast()
}
}
const currentProcesses = ref<RunningProcess[]>([]) const currentProcesses = ref<RunningProcess[]>([])
const selectedProcess = ref<RunningProcess | undefined>() const selectedProcess = ref<RunningProcess | undefined>()
@@ -260,7 +294,7 @@ onMounted(() => {
window.addEventListener('online', handleOnline) window.addEventListener('online', handleOnline)
}) })
const unlistenProcess = await process_listener(async () => { useAppEvent('process', async () => {
await refresh() await refresh()
}) })
@@ -290,6 +324,7 @@ function goToTerminal(instanceId?: string) {
const currentLoadingBars = ref<LoadingBar[]>([]) const currentLoadingBars = ref<LoadingBar[]>([])
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({}) const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
const notificationId = ref<string | number | null>(null) const notificationId = ref<string | number | null>(null)
const terminalNotificationIds = new Map<string, string | number>()
const dismissed = ref(false) const dismissed = ref(false)
function getLoadingBarKey(loadingBar: LoadingBar): string { function getLoadingBarKey(loadingBar: LoadingBar): string {
@@ -335,6 +370,44 @@ function removeNotification(): void {
notificationId.value = null notificationId.value = null
} }
function syncTerminalNotifications(): void {
const terminalNotifications = installJobNotifications.terminalNotifications.value
const currentJobIds = new Set(terminalNotifications.map((notification) => notification.id))
for (const terminal of terminalNotifications) {
const popupId = terminalNotificationIds.get(terminal.id)
let notification = popupId
? popupNotificationManager.getNotifications().find((candidate) => candidate.id === popupId)
: undefined
if (!notification) {
notification = popupNotificationManager.addPopupNotification({
title: terminal.title,
text: terminal.text,
type: terminal.type,
buttons: terminal.buttons,
onDismiss: terminal.onDismiss,
autoCloseMs: null,
})
terminalNotificationIds.set(terminal.id, notification.id)
continue
}
notification.title = terminal.title
notification.text = terminal.text
notification.type = terminal.type
notification.buttons = terminal.buttons
notification.onDismiss = terminal.onDismiss
}
for (const [jobId, popupId] of terminalNotificationIds) {
if (!currentJobIds.has(jobId)) {
popupNotificationManager.removeNotification(popupId)
terminalNotificationIds.delete(jobId)
}
}
}
function buildDownloadItems(): PopupNotificationProgressItem[] { function buildDownloadItems(): PopupNotificationProgressItem[] {
return [ return [
...installJobNotifications.progressItems.value, ...installJobNotifications.progressItems.value,
@@ -345,19 +418,20 @@ function buildDownloadItems(): PopupNotificationProgressItem[] {
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null, iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
progress: getLoadingProgress(bar), progress: getLoadingProgress(bar),
waiting: !bar.total || bar.total <= 0, waiting: !bar.total || bar.total <= 0,
progressType: 'percentage', progressType: bar.bar_type?.type === 'pack_import' ? 'bytes' : 'percentage',
progressCurrent: bar.current, progressCurrent: bar.current,
progressTotal: bar.total, progressTotal: bar.total,
})), })),
] ]
} }
const hasVisibleActiveDownloadToasts = computed(() => !!getNotification())
const hasActiveLoadingBars = computed( const hasActiveLoadingBars = computed(
() => currentLoadingBars.value.length > 0 || installJobNotifications.active.value, () => currentLoadingBars.value.length > 0 || installJobNotifications.active.value,
) )
function updateNotification(resummon = false): void { function updateNotification(resummon = false): void {
syncTerminalNotifications()
if (resummon) { if (resummon) {
dismissed.value = false dismissed.value = false
} }
@@ -386,7 +460,6 @@ function updateNotification(resummon = false): void {
: formatMessage(messages.downloads) : formatMessage(messages.downloads)
notif.text = undefined notif.text = undefined
notif.progressItems = progressItems notif.progressItems = progressItems
notif.buttons = installJobNotifications.buttons.value
notif.progress = undefined notif.progress = undefined
notif.waiting = undefined notif.waiting = undefined
} else { } else {
@@ -397,7 +470,6 @@ function updateNotification(resummon = false): void {
type: 'download', type: 'download',
autoCloseMs: null, autoCloseMs: null,
progressItems, progressItems,
buttons: installJobNotifications.buttons.value,
}) })
notificationId.value = notif.id notificationId.value = notif.id
} }
@@ -482,7 +554,7 @@ const installJobNotifications = await useInstallJobNotifications({
await refreshLoadingBars() await refreshLoadingBars()
const unlistenLoading = await loading_listener(async () => { useAppEvent('loading', async () => {
await refreshLoadingBars() await refreshLoadingBars()
}) })
@@ -496,11 +568,11 @@ function selectProcess(process: RunningProcess) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
removeNotification() removeNotification()
terminalNotificationIds.forEach((id) => popupNotificationManager.removeNotification(id))
terminalNotificationIds.clear()
dismissed.value = false dismissed.value = false
window.removeEventListener('offline', handleOffline) window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline) window.removeEventListener('online', handleOnline)
unlistenProcess()
unlistenLoading()
installJobNotifications.dispose() installJobNotifications.dispose()
}) })
</script> </script>
@@ -9,7 +9,13 @@ import {
WrenchIcon, WrenchIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui' import {
Button,
ButtonLink,
Collapsible,
IconButton,
injectNotificationManager,
} from '@modrinth/ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons' import { ChatIcon } from '@/assets/icons'
@@ -273,12 +279,10 @@ async function copyToClipboard(text) {
</template> </template>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ButtonStyled> <ButtonLink :href="supportLink" @click="errorModal.hide()"
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a> ><ChatIcon /> Get support</ButtonLink
</ButtonStyled> >
<ButtonStyled v-if="closable"> <Button v-if="closable" @click="errorModal.hide()"><XIcon /> Close</Button>
<button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled>
</div> </div>
<template v-if="hasDebugInfo"> <template v-if="hasDebugInfo">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -307,16 +311,15 @@ async function copyToClipboard(text) {
> >
{{ debugInfo }} {{ debugInfo }}
</div> </div>
<ButtonStyled circular> <IconButton
<button v-tooltip="'Copy debug info'"
v-tooltip="'Copy debug info'" :label="'Copy debug info'"
:disabled="copied" :disabled="copied"
@click="copyToClipboard(debugInfo)" @click="copyToClipboard(debugInfo)"
> >
<template v-if="copied"> <CheckIcon class="text-green" /> </template> <template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template> <template v-else> <CopyIcon /> </template>
</button> </IconButton>
</ButtonStyled>
</div> </div>
</Collapsible> </Collapsible>
</div> </div>
@@ -1,27 +1,25 @@
<script setup> <script setup>
import { XIcon } from '@modrinth/assets' import { FolderOpenIcon, XIcon } from '@modrinth/assets'
import { import {
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessages, defineMessages,
FileTreeSelect, FileTreeSelect,
injectNotificationManager, injectNotificationManager,
injectPopupNotificationManager,
NewModal, NewModal,
StyledInput, StyledInput,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog' import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs' import { ref, shallowRef } from 'vue'
import { ref } from 'vue'
import { PackageIcon } from '@/assets/icons' import { PackageIcon } from '@/assets/icons'
import { import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
export_instance_mrpack, import { highlightInFolder } from '@/helpers/utils'
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const messages = defineMessages({ const messages = defineMessages({
@@ -44,6 +42,14 @@ const messages = defineMessages({
defaultMessage: 'Enter modpack description...', defaultMessage: 'Enter modpack description...',
}, },
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' }, exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
exportComplete: {
id: 'app.export-modal.export-complete',
defaultMessage: 'Export complete',
},
exportCompleteDescription: {
id: 'app.export-modal.export-complete-description',
defaultMessage: '{name} was exported successfully.',
},
}) })
const props = defineProps({ const props = defineProps({
@@ -65,30 +71,24 @@ const exportModal = ref(null)
const nameInput = ref(props.instance.name) const nameInput = ref(props.instance.name)
const exportDescription = ref('') const exportDescription = ref('')
const versionInput = ref('1.0.0') const versionInput = ref('1.0.0')
const files = ref([]) const files = shallowRef([])
const selectedFilePaths = ref([]) const includedFilePaths = ref([])
const excludedFilePaths = ref([])
const fileTreeKey = ref(0) const fileTreeKey = ref(0)
const filesLoadId = ref(0) const filesLoadId = ref(0)
const instanceRoot = ref('') const directoryEntries = new Map()
const loadedDirectories = ref(new Set()) const currentDirectory = ref('')
async function initFiles() { async function initFiles() {
const loadId = ++filesLoadId.value const loadId = ++filesLoadId.value
const [filePaths, root] = await Promise.all([ const exportCandidates = await get_pack_export_candidates(props.instance.id)
get_pack_export_candidates(props.instance.id),
get_full_path(props.instance.id),
])
if (loadId !== filesLoadId.value) return
instanceRoot.value = root
const exportCandidates = await Promise.all(
filePaths.map((path) => buildExportCandidateItem(root, path)),
)
if (loadId !== filesLoadId.value) return if (loadId !== filesLoadId.value) return
files.value = exportCandidates files.value = exportCandidates
selectedFilePaths.value = files.value directoryEntries.set('', exportCandidates)
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path)) currentDirectory.value = ''
includedFilePaths.value = files.value
.filter((file) => !file.disabled && file.defaultSelected)
.map((file) => file.path) .map((file) => file.path)
} }
@@ -104,15 +104,35 @@ const exportPack = async () => {
}) })
if (outputPath) { if (outputPath) {
export_instance_mrpack(
props.instance.id,
outputPath,
selectedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
).catch((err) => handleError(err))
exportModal.value.hide() exportModal.value.hide()
try {
await export_instance_mrpack(
props.instance.id,
outputPath,
includedFilePaths.value,
excludedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
)
const fileName = outputPath.split(/[\\/]/).pop() ?? outputPath
popupNotificationManager.addPopupNotification({
title: formatMessage(messages.exportComplete),
text: formatMessage(messages.exportCompleteDescription, { name: fileName }),
type: 'success',
buttons: [
{
label: formatMessage(commonMessages.openInFolderButton),
icon: FolderOpenIcon,
action: () => highlightInFolder(outputPath).catch(handleError),
},
],
})
} catch (error) {
handleError(error)
}
} }
} }
@@ -121,115 +141,45 @@ function resetExportState() {
exportDescription.value = '' exportDescription.value = ''
versionInput.value = '1.0.0' versionInput.value = '1.0.0'
files.value = [] files.value = []
selectedFilePaths.value = [] includedFilePaths.value = []
excludedFilePaths.value = []
fileTreeKey.value += 1 fileTreeKey.value += 1
instanceRoot.value = '' directoryEntries.clear()
loadedDirectories.value = new Set() currentDirectory.value = ''
} }
async function loadExportDirectory(path) { async function loadExportDirectory(path) {
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return const normalizedPath = normalizeExportPath(path)
currentDirectory.value = normalizedPath
const cachedEntries = directoryEntries.get(normalizedPath)
if (cachedEntries) {
files.value = cachedEntries
return
}
const loadId = filesLoadId.value const loadId = filesLoadId.value
loadedDirectories.value.add(path) files.value = []
try { try {
const entries = await readDir(`${instanceRoot.value}/${path}`) const childItems = await get_pack_export_candidates(
const childItems = await Promise.all( props.instance.id,
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)), normalizedPath || undefined,
) )
if (loadId !== filesLoadId.value) return if (loadId !== filesLoadId.value) return
appendExportItems(childItems) directoryEntries.set(normalizedPath, childItems)
} catch { if (currentDirectory.value === normalizedPath) {
loadedDirectories.value.delete(path) files.value = childItems
}
}
async function buildExportCandidateItem(instanceRoot, path) {
try {
const entries = await readDir(`${instanceRoot}/${path}`)
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
count: entries.length,
} }
} catch { } catch {
return buildExportFileItem(instanceRoot, path) if (currentDirectory.value === normalizedPath) files.value = []
}
}
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
const path = `${parentPath}/${entry.name}`
if (entry.isDirectory) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
}
}
return buildExportFileItem(instanceRoot, path)
}
async function buildExportFileItem(instanceRoot, path) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'file',
disabled: isExportCandidateDisabled(path),
size: metadata.size,
modified: metadata.modified,
}
}
function appendExportItems(items) {
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
for (const item of items) {
nextFiles.set(normalizeExportPath(item.path), item)
}
files.value = [...nextFiles.values()]
}
async function getExportCandidateMetadata(instanceRoot, path) {
try {
const metadata = await stat(`${instanceRoot}/${path}`)
return {
size: metadata.size,
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
}
} catch {
return {}
} }
} }
function normalizeExportPath(path) { function normalizeExportPath(path) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/') return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
} }
function isDefaultSelectedExportCandidate(path) {
return (
path.startsWith('mods') ||
path.startsWith('datapacks') ||
path.startsWith('resourcepacks') ||
path.startsWith('shaderpacks') ||
path.startsWith('config')
)
}
function isExportCandidateDisabled(path) {
return (
path === 'profile.json' ||
path.startsWith('modrinth_logs') ||
path.startsWith('.fabric') ||
path.startsWith('__MACOSX')
)
}
</script> </script>
<template> <template>
@@ -278,26 +228,24 @@ function isExportCandidateDisabled(path) {
</div> </div>
<FileTreeSelect <FileTreeSelect
:key="fileTreeKey" :key="fileTreeKey"
v-model="selectedFilePaths" v-model="includedFilePaths"
v-model:excluded-paths="excludedFilePaths"
class="min-w-0" class="min-w-0"
:items="files" :items="files"
lazy
@navigate="loadExportDirectory" @navigate="loadExportDirectory"
/> />
</div> </div>
<template #actions> <template #actions>
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined"> <Button type="outlined" @click="exportModal.hide">
<button @click="exportModal.hide"> <XIcon />
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button type="colored" color="brand" @click="exportPack">
</ButtonStyled> <PackageIcon />
<ButtonStyled color="brand"> {{ formatMessage(messages.exportButton) }}
<button @click="exportPack"> </Button>
<PackageIcon />
{{ formatMessage(messages.exportButton) }}
</button>
</ButtonStyled>
</div> </div>
</template> </template>
</NewModal> </NewModal>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { DownloadIcon, ExcitedRinthbot, RefreshCwIcon, ServerStackIcon } from '@modrinth/assets'
import { Button, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import {
appUpdateState,
downloadAvailableAppUpdate,
installAvailableAppUpdate,
} from '@/providers/app-update'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.hosting.update-required.title',
defaultMessage: 'Modrinth App update required',
},
description: {
id: 'app.hosting.update-required.description',
defaultMessage: 'You need to update to use Modrinth Hosting through the Modrinth App',
},
downloadToUpdate: {
id: 'app.hosting.update-required.download',
defaultMessage: 'Download to update',
},
downloadingUpdate: {
id: 'app.action-bar.downloading-update',
defaultMessage: 'Downloading update',
},
reloadToUpdate: {
id: 'app.action-bar.reload-to-update',
defaultMessage: 'Reload to update',
},
excitedRinthbotAlt: {
id: 'app.hosting.update-required.rinthbot-alt',
defaultMessage: 'Excited Modrinth Bot',
},
})
useRootBreadcrumb({
slot: 'root',
id: 'servers',
label: () => formatMessage(commonMessages.serversLabel),
to: '/hosting/manage/',
visual: { type: 'icon', component: ServerStackIcon },
})
const { downloading, downloadPercent, downloadProgress, finishedDownloading } = appUpdateState
const isUpdateDownloading = computed(
() =>
downloading.value ||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
)
async function handleUpdateClick() {
if (isUpdateDownloading.value) {
return
}
if (finishedDownloading.value) {
await installAvailableAppUpdate()
} else {
await downloadAvailableAppUpdate()
}
}
</script>
<template>
<div class="box-border flex min-h-full items-center justify-center p-4">
<div class="relative mx-auto w-full max-w-xl pt-28">
<img
:src="ExcitedRinthbot"
:alt="formatMessage(messages.excitedRinthbotAlt)"
class="absolute right-8 top-0 h-28 w-auto md:right-20"
/>
<div class="relative flex flex-col gap-5 rounded-lg bg-bg-raised p-7 shadow-lg">
<div
class="absolute left-0 top-0 h-px w-full bg-gradient-to-r from-transparent via-green-500 to-transparent opacity-40"
style="
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
"
></div>
<div class="flex flex-col gap-5">
<h1 class="m-0 text-3xl font-extrabold">
{{ formatMessage(messages.title) }}
</h1>
<p class="m-0 text-lg">
{{ formatMessage(messages.description) }}
</p>
<Button
type="colored"
color="brand"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">
{{ downloadPercent }}%
</span>
</span>
<span v-else-if="finishedDownloading">
{{ formatMessage(messages.reloadToUpdate) }}
</span>
<span v-else>{{ formatMessage(messages.downloadToUpdate) }}</span>
</Button>
</div>
</div>
</div>
</div>
</template>
@@ -7,14 +7,14 @@ import {
StopCircleIcon, StopCircleIcon,
TimerIcon, TimerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui' import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install' import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
@@ -136,7 +136,7 @@ defineExpose({
const currentEvent = ref(null) const currentEvent = ref(null)
const unlisten = await process_listener((e) => { useAppEvent('process', (e) => {
if (e.instance_id === props.instance.id) { if (e.instance_id === props.instance.id) {
currentEvent.value = e.event currentEvent.value = e.event
if (e.event === 'finished') { if (e.event === 'finished') {
@@ -148,7 +148,6 @@ const unlisten = await process_listener((e) => {
onMounted(() => { onMounted(() => {
checkProcess() checkProcess()
}) })
onUnmounted(() => unlisten())
</script> </script>
<template> <template>
@@ -168,30 +167,37 @@ onUnmounted(() => unlisten())
<span class="line-clamp-2">{{ instance.name }}</span> <span class="line-clamp-2">{{ instance.name }}</span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess"> <IconButton
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')"> v-if="playing"
<StopCircleIcon /> v-tooltip="'Stop'"
</button> type="colored"
</ButtonStyled> color="red"
<ButtonStyled v-else-if="modLoading" color="standard" circular> :label="'Stop'"
<button v-tooltip="'Instance is loading...'" disabled> @mouseenter="checkProcess"
<SpinnerIcon class="animate-spin" /> @click="(e) => stop(e, 'InstanceCard')"
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="!instance.quarantined"
:color="first ? 'brand' : 'standard'"
circular
> >
<button <StopCircleIcon />
v-tooltip="'Play'" </IconButton>
@click="(e) => play(e, 'InstanceCard')" <IconButton
@mousehover="checkProcess" v-else-if="modLoading"
> v-tooltip="'Instance is loading...'"
<!-- Translate for optical centering --> :label="'Instance is loading...'"
<PlayIcon class="translate-x-[1px]" /> disabled
</button> >
</ButtonStyled> <SpinnerIcon class="animate-spin" />
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="'Play'"
:type="first ? 'colored' : 'base'"
:color="first ? 'brand' : undefined"
label="Play"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" />
</IconButton>
</div> </div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon /> <TimerIcon />
@@ -219,47 +225,51 @@ onUnmounted(() => unlisten())
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`" :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"> <div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="playing" size="large" color="red" circular> <IconButton
<button v-if="playing"
v-tooltip="'Stop'" v-tooltip="'Stop'"
:class="{ 'scale-100 opacity-100': playing }" type="colored"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow" color="red"
@click="(e) => stop(e, 'InstanceCard')" size="xl"
@mousehover="checkProcess" :label="'Stop'"
> :class="{ 'scale-100 opacity-100': playing }"
<StopCircleIcon /> class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
</button> @click="(e) => stop(e, 'InstanceCard')"
</ButtonStyled> @mouseenter="checkProcess"
>
<StopCircleIcon />
</IconButton>
<SpinnerIcon <SpinnerIcon
v-else-if="modLoading || installing" v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'" v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="animate-spin w-8 h-8" class="animate-spin w-8 h-8"
tabindex="-1" tabindex="-1"
/> />
<ButtonStyled <IconButton
v-else-if="!installed && !instance.quarantined" v-else-if="!installed && !instance.quarantined"
size="large" v-tooltip="'Repair'"
type="colored"
color="brand" color="brand"
circular size="xl"
:label="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
> >
<button <DownloadIcon />
v-tooltip="'Repair'" </IconButton>
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" <IconButton
@click="(e) => repair(e)" v-else-if="!instance.quarantined"
> v-tooltip="'Play'"
<DownloadIcon /> type="colored"
</button> color="brand"
</ButtonStyled> size="xl"
<ButtonStyled v-else-if="!instance.quarantined" size="large" color="brand" circular> :label="'Play'"
<button 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"
v-tooltip="'Play'" @click="(e) => play(e, 'InstanceCard')"
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" @mouseenter="checkProcess"
@click="(e) => play(e, 'InstanceCard')" >
@mousehover="checkProcess" <PlayIcon class="translate-x-[2px]" />
> </IconButton>
<PlayIcon class="translate-x-[2px]" />
</button>
</ButtonStyled>
</div> </div>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { GameIcon, LeftArrowIcon } from '@modrinth/assets' import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, FormattedTag } from '@modrinth/ui' import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { computed } from 'vue' import { computed } from 'vue'
@@ -48,9 +48,7 @@ const instanceLink = computed(() => {
</span> </span>
</span> </span>
</router-link> </router-link>
<ButtonStyled> <ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </ButtonLink>
<router-link :to="instanceLink"> <LeftArrowIcon /> Back to instance </router-link>
</ButtonStyled>
</div> </div>
</template> </template>
@@ -14,18 +14,14 @@
</template> </template>
<template #cell-actions="{ row }"> <template #cell-actions="{ row }">
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<ButtonStyled v-if="currentSelected.path === row.path"> <Button v-if="currentSelected.path === row.path" disabled>
<button class="!shadow-none" disabled> <CheckIcon aria-hidden="true" />
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.selected) }}
{{ formatMessage(messages.selected) }} </Button>
</button> <Button v-else @click="setJavaInstall(row)">
</ButtonStyled> <PlusIcon aria-hidden="true" />
<ButtonStyled v-else> {{ formatMessage(messages.select) }}
<button class="!shadow-none" @click="setJavaInstall(row)"> </Button>
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.select) }}
</button>
</ButtonStyled>
</div> </div>
</template> </template>
<template #empty-state> <template #empty-state>
@@ -35,28 +31,21 @@
</template> </template>
</Table> </Table>
<div class="flex justify-end"> <div class="flex justify-end">
<ButtonStyled type="outlined"> <Button
<button type="outlined"
class="!shadow-none !border-surface-4 !border" class="!border-surface-4 !border"
@click="$refs.detectJavaModal.hide()" @click="$refs.detectJavaModal.hide()"
> >
<XIcon aria-hidden="true" /> <XIcon aria-hidden="true" />
{{ formatMessage(messages.cancel) }} {{ formatMessage(messages.cancel) }}
</button> </Button>
</ButtonStyled>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
<script setup> <script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets' import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { import { Button, defineMessages, injectNotificationManager, Table, useVIntl } from '@modrinth/ui'
ButtonStyled,
defineMessages,
injectNotificationManager,
Table,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -17,64 +17,73 @@
} }
" "
/> />
<ButtonStyled <Button
type="quiet"
:color=" :color="
!hoveringTest && !testingJava !hoveringTest && !testingJava
? testingJavaSuccess === true ? testingJavaSuccess === true
? 'green' ? 'green'
: 'red' : 'red'
: 'standard' : undefined
" "
color-fill="text" :aria-label="formatMessage(messages.testJavaInstallation)"
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
:disabled="testingJava || props.disabled"
:style="{
'--legacy-button-color':
(!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard') &&
(!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard') !== 'standard'
? `var(--color-${
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
})`
: undefined,
}"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
> >
<button <SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
:aria-label="formatMessage(messages.testJavaInstallation)" <CheckCircleIcon v-else-if="testingJavaSuccess === true && !hoveringTest" class="h-4 w-4" />
class="!shadow-none" <XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
:disabled="testingJava || props.disabled" <RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
@click="runTest(props.modelValue?.path)" </Button>
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="testingJavaSuccess === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</button>
</ButtonStyled>
</div> </div>
<span class="installation-buttons"> <span class="installation-buttons">
<ButtonStyled v-if="props.version"> <Button
<button v-if="props.version"
v-tooltip=" v-tooltip="
testingJavaSuccess === true ? formatMessage(messages.alreadyInstalled) : undefined testingJavaSuccess === true ? formatMessage(messages.alreadyInstalled) : undefined
" "
class="!shadow-none" :disabled="props.disabled || installingJava || testingJavaSuccess === true"
:disabled="props.disabled || installingJava || testingJavaSuccess === true" @click="reinstallJava"
@click="reinstallJava" >
> <DownloadIcon />
<DownloadIcon /> {{
{{ installingJava
installingJava ? formatMessage(messages.installing)
? formatMessage(messages.installing) : formatMessage(messages.installRecommended)
: formatMessage(messages.installRecommended) }}
}} </Button>
</button> <Button :disabled="props.disabled" @click="autoDetect">
</ButtonStyled> <SearchIcon />
<ButtonStyled> {{ formatMessage(messages.detect) }}
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect"> </Button>
<SearchIcon /> <Button :disabled="props.disabled" @click="handleJavaFileInput()">
{{ formatMessage(messages.detect) }} <FolderSearchIcon />
</button> {{ formatMessage(messages.browse) }}
</ButtonStyled> </Button>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
{{ formatMessage(messages.browse) }}
</button>
</ButtonStyled>
</span> </span>
</div> </div>
</template> </template>
@@ -90,7 +99,7 @@ import {
XCircleIcon, XCircleIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
ButtonStyled, Button,
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
StyledInput, StyledInput,
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { CheckIcon } from '@modrinth/assets' import { CheckIcon } from '@modrinth/assets'
import { Badge, ButtonStyled } from '@modrinth/ui' import { Badge, IconButton } from '@modrinth/ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js' import { SwapIcon } from '@/assets/icons/index.js'
@@ -74,18 +74,16 @@ const onHide = () => {
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)" @click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
> >
<div class="table-cell table-text"> <div class="table-cell table-text">
<ButtonStyled <IconButton
circular :type="version.id === installedVersion ? 'base' : 'colored'"
:color="version.id === installedVersion ? 'standard' : 'brand'" :color="version.id === installedVersion ? undefined : 'brand'"
label="Switch version"
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
> >
<button <SwapIcon v-if="version.id !== installedVersion" />
:disabled="inProgress || installing || version.id === installedVersion" <CheckIcon v-else />
@click.stop="() => switchVersion(version.id)" </IconButton>
>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</button>
</ButtonStyled>
</div> </div>
<div class="name-cell table-cell table-text"> <div class="name-cell table-cell table-text">
<div class="version-link"> <div class="version-link">
@@ -7,8 +7,9 @@ import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue' import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js' import { useAppEvent } from '@/composables/use-app-event'
import { list } from '@/helpers/instance' import { list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52 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 = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
@@ -123,7 +124,7 @@ const getInstances = async () => {
const instances = await list().catch(handleError) const instances = await list().catch(handleError)
for (const instance of instances) { for (const instance of instances) {
queryClient.setQueryData(['instances', 'summary', instance.id], instance) queryClient.setQueryData(instanceKeys.detail(instance.id), instance)
} }
allInstances.value = instances.sort((a, b) => { allInstances.value = instances.sort((a, b) => {
@@ -147,7 +148,7 @@ const getInstances = async () => {
await getInstances() await getInstances()
updateMaxAuto() updateMaxAuto()
const unlistenInstance = await instance_listener(async (event) => { useAppEvent('instance', async (event) => {
if (event.event !== 'synced') { if (event.event !== 'synced') {
await getInstances() await getInstances()
} }
@@ -161,7 +162,6 @@ onUnmounted(() => {
window.removeEventListener('resize', updateMaxAuto) window.removeEventListener('resize', updateMaxAuto)
document.body.classList.remove('quick-instance-dragging') document.body.classList.remove('quick-instance-dragging')
clearOverdragFlash() clearOverdragFlash()
unlistenInstance()
}) })
const messages = defineMessages({ const messages = defineMessages({
@@ -82,7 +82,7 @@ import { injectLoadingState } from '@modrinth/ui'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import ProgressBar from '@/components/ui/ProgressBar.vue' import ProgressBar from '@/components/ui/ProgressBar.vue'
import { loading_listener } from '@/helpers/events.js' import { useAppEvent } from '@/composables/use-app-event'
const doneLoading = ref(false) const doneLoading = ref(false)
const loadingProgress = ref(0) const loadingProgress = ref(0)
@@ -132,13 +132,10 @@ function fakeLoadingIncrease() {
} }
} }
loading_listener(async (e) => { useAppEvent('loading', (e) => {
if (e.event.type === 'directory_move') { if (e.event.type === 'directory_move') {
loadingProgress.value = 100 * (e.fraction ?? 1) loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Updating app directory...' message.value = 'Updating app directory...'
} else if (e.event.type === 'checking_for_updates') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Checking for updates...'
} }
}) })
</script> </script>
@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { NotepadTextIcon, XIcon } from '@modrinth/assets' import { NotepadTextIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui' import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { type } from '@tauri-apps/plugin-os' import { type } from '@tauri-apps/plugin-os'
import { $fetch } from 'ofetch' import { $fetch } from 'ofetch'
import { onMounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js' import { release_ads_window_hold, take_ads_window_hold } from '@/helpers/ads.js'
import { list } from '@/helpers/instance' import { list } from '@/helpers/instance'
import { get as getCreds } from '@/helpers/mr_auth.ts' import { get as getCreds } from '@/helpers/mr_auth.ts'
let adsWindowHold = false
type Survey = { type Survey = {
id: string id: string
tally_id: string tally_id: string
@@ -92,30 +94,44 @@ async function openSurvey() {
onOpen: () => console.info('Opened user survey'), onOpen: () => console.info('Opened user survey'),
onClose: () => { onClose: () => {
console.info('Closed user survey') console.info('Closed user survey')
show_ads_window() if (adsWindowHold) {
adsWindowHold = false
release_ads_window_hold()
}
}, },
onSubmit: () => console.info('Active user survey submitted'), onSubmit: () => console.info('Active user survey submitted'),
} }
try { try {
hide_ads_window() await take_ads_window_hold()
adsWindowHold = true
if (tallyWindow.Tally?.openPopup) { if (tallyWindow.Tally?.openPopup) {
console.info(`Opening Tally popup for user survey (form ID: ${formId})`) console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
dismissSurvey() dismissSurvey()
tallyWindow.Tally.openPopup(formId, popupOptions) tallyWindow.Tally.openPopup(formId, popupOptions)
} else { } else {
console.warn('Tally script not yet loaded') console.warn('Tally script not yet loaded')
show_ads_window() adsWindowHold = false
await release_ads_window_hold()
} }
} catch (e) { } catch (e) {
console.error('Error opening Tally popup:', e) console.error('Error opening Tally popup:', e)
show_ads_window() if (adsWindowHold) {
adsWindowHold = false
await release_ads_window_hold()
}
} }
console.info(`Found user survey to show with tally_id: ${formId}`) console.info(`Found user survey to show with tally_id: ${formId}`)
tallyWindow.Tally?.openPopup(formId, popupOptions)
} }
onUnmounted(() => {
if (adsWindowHold) {
adsWindowHold = false
release_ads_window_hold()
}
})
function dismissSurvey() { function dismissSurvey() {
if (!availableSurvey.value) return if (!availableSurvey.value) return
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, String(new Date())) localStorage.setItem(`survey-${availableSurvey.value.id}-display`, String(new Date()))
@@ -193,18 +209,14 @@ onMounted(async () => {
{{ formatMessage(messages.surveyFooter) }} {{ formatMessage(messages.surveyFooter) }}
</p> </p>
<div class="flex gap-2"> <div class="flex gap-2">
<ButtonStyled color="brand"> <Button type="colored" color="brand" @click="openSurvey">
<button @click="openSurvey"> <NotepadTextIcon />
<NotepadTextIcon /> {{ formatMessage(messages.takeSurvey) }}
{{ formatMessage(messages.takeSurvey) }} </Button>
</button> <Button @click="dismissSurvey">
</ButtonStyled> <XIcon />
<ButtonStyled> {{ formatMessage(messages.surveyNoThanks) }}
<button @click="dismissSurvey"> </Button>
<XIcon />
{{ formatMessage(messages.surveyNoThanks) }}
</button>
</ButtonStyled>
</div> </div>
</div> </div>
</transition> </transition>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ButtonStyled, injectNotificationManager, ProjectCard } from '@modrinth/ui' import { Button, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -69,9 +69,7 @@ async function install() {
</p> </p>
</div> </div>
<div class="button-group"> <div class="button-group">
<ButtonStyled color="brand"> <Button type="colored" color="brand" @click="install">Install</Button>
<button @click="install">Install</button>
</ButtonStyled>
</div> </div>
</div> </div>
</div> </div>
@@ -4,34 +4,37 @@
class="flex items-center gap-2 mr-1.5" class="flex items-center gap-2 mr-1.5"
data-tauri-drag-region-exclude data-tauri-drag-region-exclude
> >
<ButtonStyled type="transparent" circular> <IconButton
<button class="relative expanded-button" @click="() => getCurrentWindow().minimize()"> type="quiet"
<MinimizeIcon /> label="Minimize window"
</button> class="relative expanded-button"
</ButtonStyled> @click="() => getCurrentWindow().minimize()"
<ButtonStyled type="transparent" circular>
<button class="relative expanded-button" @click="() => getCurrentWindow().toggleMaximize()">
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled
type="transparent"
color="red"
color-fill="none"
hover-color-fill="background"
circular
> >
<button class="relative expanded-button close-button" @click="handleClose"> <MinimizeIcon />
<XIcon /> </IconButton>
</button> <IconButton
</ButtonStyled> type="quiet"
label="Toggle maximize window"
class="relative expanded-button"
@click="() => getCurrentWindow().toggleMaximize()"
>
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</IconButton>
<IconButton
type="quiet"
label="Close window"
class="relative expanded-button close-button"
@click="handleClose"
>
<XIcon />
</IconButton>
</section> </section>
</template> </template>
<script setup> <script setup>
import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets' import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui' import { IconButton } from '@modrinth/ui'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
@@ -1,31 +1,30 @@
<template> <template>
<ButtonStyled color="brand" type="outlined" hover-color-fill="background"> <Button
<button v-if="showUpdatePill"
v-if="showUpdatePill" type="outlined"
type="button" native-type="button"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out" 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="{ :class="{
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill, 'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
'opacity-100 scale-100': finishedDownloading && animateReadyPill, 'opacity-100 scale-100': finishedDownloading && animateReadyPill,
}" }"
:disabled="isUpdateDownloading" :disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading" :aria-busy="isUpdateDownloading"
@click="handleUpdateClick" @click="handleUpdateClick"
> >
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" /> <RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else /> <DownloadIcon v-else />
<span v-if="isUpdateDownloading"> <span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }} {{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span> <span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span> </span>
<span v-else>{{ updateLabel }}</span> <span v-else>{{ updateLabel }}</span>
</button> </Button>
</ButtonStyled>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets' import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui' import { Button, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { import {
@@ -2,8 +2,9 @@
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets' import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, Button,
defineMessages, defineMessages,
IconButton,
injectNotificationManager, injectNotificationManager,
IntlFormatted, IntlFormatted,
StyledInput, StyledInput,
@@ -17,17 +18,40 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useFriends } from '@/composables/use-friends' import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts' import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth' 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 { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const themeStore = useTheming()
const props = defineProps<{ const props = defineProps<{
credentials: ModrinthCredentials | null credentials: ModrinthCredentials | null
signIn: () => void signIn: () => void
}>() }>()
type FriendsSectionCollapsedFlag =
| 'friends_active_collapsed'
| 'friends_online_collapsed'
| 'friends_offline_collapsed'
| 'friends_pending_collapsed'
function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
return themeStore.getFeatureFlag(flag)
}
function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
themeStore.featureFlags[flag] = collapsed
getSettings()
.then((settings) => {
settings.feature_flags[flag] = collapsed
return setSettings(settings)
})
.catch(handleError)
}
const userCredentials = computed(() => props.credentials) const userCredentials = computed(() => props.credentials)
const { const {
friends: userFriends, friends: userFriends,
@@ -197,26 +221,20 @@ const messages = defineMessages({
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<template v-if="friend.id === userCredentials?.user_id"> <template v-if="friend.id === userCredentials?.user_id">
<ButtonStyled color="brand"> <Button type="colored" color="brand" @click="addFriend(friend)">
<button @click="addFriend(friend)"> <UserPlusIcon />
<UserPlusIcon /> Accept
Accept </Button>
</button> <Button @click="removeFriend(friend)">
</ButtonStyled> <XIcon />
<ButtonStyled> Ignore
<button @click="removeFriend(friend)"> </Button>
<XIcon />
Ignore
</button>
</ButtonStyled>
</template> </template>
<template v-else> <template v-else>
<ButtonStyled> <Button @click="removeFriend(friend)">
<button @click="removeFriend(friend)"> <XIcon />
<XIcon /> Cancel
Cancel </Button>
</button>
</ButtonStyled>
</template> </template>
</div> </div>
</div> </div>
@@ -240,26 +258,28 @@ const messages = defineMessages({
wrapper-class="flex-1" wrapper-class="flex-1"
@keyup.enter="addFriendFromModal" @keyup.enter="addFriendFromModal"
/> />
<ButtonStyled color="brand"> <Button
<button :disabled="username.length === 0" @click="addFriendFromModal"> type="colored"
<SendIcon /> color="brand"
{{ formatMessage(messages.sendFriendRequest) }} :disabled="username.length === 0"
</button> @click="addFriendFromModal"
</ButtonStyled> >
<SendIcon />
{{ formatMessage(messages.sendFriendRequest) }}
</Button>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 -ml-1"> <div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 -ml-1">
<template v-if="sortedFriends.length > 0"> <template v-if="sortedFriends.length > 0">
<ButtonStyled circular type="transparent"> <IconButton
<button v-tooltip="formatMessage(messages.addFriend)"
v-tooltip="formatMessage(messages.addFriend)" type="quiet"
:aria-label="formatMessage(messages.addFriend)" :label="formatMessage(messages.addFriend)"
@click="addFriendModal.show" @click="addFriendModal.show"
> >
<UserPlusIcon /> <UserPlusIcon />
</button> </IconButton>
</ButtonStyled>
<StyledInput <StyledInput
v-model="search" v-model="search"
:icon="SearchIcon" :icon="SearchIcon"
@@ -274,23 +294,23 @@ const messages = defineMessages({
<h3 v-else class="w-full text-base text-primary font-medium m-0"> <h3 v-else class="w-full text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }} {{ formatMessage(messages.friends) }}
</h3> </h3>
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent"> <IconButton
<button v-if="incomingRequests.length > 0"
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })" v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
class="relative" type="quiet"
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })" :label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
@click="friendInvitesModal.show" class="relative"
@click="friendInvitesModal.show"
>
<MailIcon />
<span
v-if="incomingRequests.length > 0"
aria-hidden="true"
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
> >
<MailIcon /> {{ incomingRequests.length }}
<span </span>
v-if="incomingRequests.length > 0" </IconButton>
aria-hidden="true"
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
>
{{ incomingRequests.length }}
</span>
</button>
</ButtonStyled>
</div> </div>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<h3 v-if="loading" class="text-base text-primary font-medium m-0"> <h3 v-if="loading" class="text-base text-primary font-medium m-0">
@@ -331,33 +351,42 @@ const messages = defineMessages({
<FriendsSection <FriendsSection
v-if="activeFriends.length > 0" v-if="activeFriends.length > 0"
:is-searching="!!search" :is-searching="!!search"
open-by-default :open-by-default="!isFriendsSectionCollapsed('friends_active_collapsed')"
:friends="activeFriends" :friends="activeFriends"
:heading="formatMessage(messages.active)" :heading="formatMessage(messages.active)"
:remove-friend="removeFriend" :remove-friend="removeFriend"
@on-open="setFriendsSectionCollapsed('friends_active_collapsed', false)"
@on-close="setFriendsSectionCollapsed('friends_active_collapsed', true)"
/> />
<FriendsSection <FriendsSection
v-if="onlineFriends.length > 0" v-if="onlineFriends.length > 0"
:is-searching="!!search" :is-searching="!!search"
open-by-default :open-by-default="!isFriendsSectionCollapsed('friends_online_collapsed')"
:friends="onlineFriends" :friends="onlineFriends"
:heading="formatMessage(messages.online)" :heading="formatMessage(messages.online)"
:remove-friend="removeFriend" :remove-friend="removeFriend"
@on-open="setFriendsSectionCollapsed('friends_online_collapsed', false)"
@on-close="setFriendsSectionCollapsed('friends_online_collapsed', true)"
/> />
<FriendsSection <FriendsSection
v-if="offlineFriends.length > 0" v-if="offlineFriends.length > 0"
:is-searching="!!search" :is-searching="!!search"
:open-by-default="activeFriends.length + onlineFriends.length < 3" :open-by-default="!isFriendsSectionCollapsed('friends_offline_collapsed')"
:friends="offlineFriends" :friends="offlineFriends"
:heading="formatMessage(messages.offline)" :heading="formatMessage(messages.offline)"
:remove-friend="removeFriend" :remove-friend="removeFriend"
@on-open="setFriendsSectionCollapsed('friends_offline_collapsed', false)"
@on-close="setFriendsSectionCollapsed('friends_offline_collapsed', true)"
/> />
<FriendsSection <FriendsSection
v-if="pendingFriends.length > 0" v-if="pendingFriends.length > 0"
:is-searching="!!search" :is-searching="!!search"
:open-by-default="!isFriendsSectionCollapsed('friends_pending_collapsed')"
:friends="pendingFriends" :friends="pendingFriends"
:heading="formatMessage(messages.pending)" :heading="formatMessage(messages.pending)"
:remove-friend="removeFriend" :remove-friend="removeFriend"
@on-open="setFriendsSectionCollapsed('friends_pending_collapsed', false)"
@on-close="setFriendsSectionCollapsed('friends_pending_collapsed', true)"
/> />
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4"> <p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
{{ formatMessage(messages.noFriendsMatch, { query: search }) }} {{ formatMessage(messages.noFriendsMatch, { query: search }) }}
@@ -3,9 +3,9 @@ import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import { import {
Accordion, Accordion,
Avatar, Avatar,
ButtonStyled,
defineMessages, defineMessages,
OverflowMenu, IconButton,
TeleportOverflowMenu,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useTemplateRef } from 'vue' import { useTemplateRef } from 'vue'
@@ -31,6 +31,11 @@ const props = withDefaults(
}, },
) )
const emit = defineEmits<{
onOpen: []
onClose: []
}>()
function createContextMenuOptions(friend: FriendWithUserData) { function createContextMenuOptions(friend: FriendWithUserData) {
if (friend.accepted) { if (friend.accepted) {
return [ return [
@@ -112,6 +117,8 @@ const messages = defineMessages({
? '' ? ''
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all') : ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
" "
@on-open="emit('onOpen')"
@on-close="emit('onClose')"
> >
<template #title> <template #title>
<h3 class="text-base text-primary font-medium m-0"> <h3 class="text-base text-primary font-medium m-0">
@@ -159,29 +166,35 @@ const messages = defineMessages({
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span> <span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div> </div>
</RouterLink> </RouterLink>
<ButtonStyled v-if="friend.accepted" circular type="transparent"> <TeleportOverflowMenu
<OverflowMenu v-if="friend.accepted"
class="opacity-0 group-hover:opacity-100 transition-opacity" type="quiet"
:options="[ label="More options"
{ class="opacity-0 group-hover:opacity-100 transition-opacity"
id: 'remove-friend', :options="[
action: () => removeFriend(friend), {
color: 'red', id: 'remove-friend',
}, label: formatMessage(messages.removeFriend),
]" action: () => removeFriend(friend),
> tone: 'red',
<MoreVerticalIcon /> },
<template #remove-friend> ]"
<TrashIcon /> >
{{ formatMessage(messages.removeFriend) }} <MoreVerticalIcon />
</template> <template #remove-friend>
</OverflowMenu> <TrashIcon />
</ButtonStyled> {{ formatMessage(messages.removeFriend) }}
<ButtonStyled v-else type="transparent" circular> </template>
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)"> </TeleportOverflowMenu>
<XIcon /> <IconButton
</button> v-else
</ButtonStyled> v-tooltip="formatMessage(messages.cancelRequest)"
type="quiet"
:label="formatMessage(messages.cancelRequest)"
@click="removeFriend(friend)"
>
<XIcon />
</IconButton>
</div> </div>
</div> </div>
</template> </template>
@@ -1,12 +1,6 @@
<script setup> <script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets' import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import { import { Admonition, Avatar, Button, injectNotificationManager, StyledInput } from '@modrinth/ui'
Admonition,
Avatar,
ButtonStyled,
injectNotificationManager,
StyledInput,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -15,6 +9,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance' import { list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts' import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
import { instanceKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -67,7 +62,7 @@ async function addServer(instance) {
try { try {
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt') await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
instance.added = true instance.added = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] }) await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.id) })
trackEvent('AddServerToInstance', { trackEvent('AddServerToInstance', {
server_name: serverName.value, server_name: serverName.value,
@@ -109,19 +104,15 @@ async function addServer(instance) {
/> />
{{ instance.name }} {{ instance.name }}
</router-link> </router-link>
<ButtonStyled> <Button :disabled="instance.added || instance.adding" @click="addServer(instance)">
<button :disabled="instance.added || instance.adding" @click="addServer(instance)"> <PlusIcon v-if="!instance.added && !instance.adding" />
<PlusIcon v-if="!instance.added && !instance.adding" /> <CheckIcon v-else-if="instance.added" />
<CheckIcon v-else-if="instance.added" /> {{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }} </Button>
</button>
</ButtonStyled>
</div> </div>
</div> </div>
<div class="input-group push-right"> <div class="input-group push-right">
<ButtonStyled> <Button @click="modal.hide()">Cancel</Button>
<button @click="modal.hide()">Cancel</button>
</ButtonStyled>
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
@@ -1,63 +0,0 @@
<template>
<div class="flex items-center flex-wrap gap-2">
<template v-if="loadingServerPing">
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerPing v-if="ping" :ping="ping" />
</template>
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
<div v-if="minecraftServer?.region || ping" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div v-if="linkedProjectV3" class="flex gap-1.5 items-center font-medium text-primary">
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instanceId"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary truncate"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
Avatar,
ServerOnlinePlayers,
ServerPing,
ServerRecentPlays,
ServerRegion,
} from '@modrinth/ui'
defineProps<{
loadingServerPing?: boolean
playersOnline?: number
statusOnline?: boolean
recentPlays?: number
ping?: number
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
linkedProjectV3?: Labrinth.Projects.v3.Project
instanceId?: string
}>()
</script>
@@ -1,34 +0,0 @@
<template>
<Admonition
type="info"
inline-actions
:header="formatMessage(messages.sharedInstanceUpdateAvailableHeader)"
>
{{ formatMessage(messages.sharedInstanceUpdateAvailableBody, { name: instanceName }) }}
<template #actions>
<ButtonStyled color="blue">
<button class="!h-10" @click="emit('review', $event)">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.sharedInstanceReviewUpdateButton) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
defineProps<{
instanceName: string
}>()
const emit = defineEmits<{
review: [event: MouseEvent]
}>()
const { formatMessage } = useVIntl()
</script>
@@ -7,10 +7,9 @@ import {
MessagesSquareIcon, MessagesSquareIcon,
WrenchIcon, WrenchIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Admonition, ButtonStyled, Collapsible, NewModal } from '@modrinth/ui' import { Admonition, Button, ButtonLink, Collapsible, IconButton, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { login as login_flow, set_default_user } from '@/helpers/auth.js' import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
@@ -29,19 +28,13 @@ function show(errorVal: { message?: string }) {
matchedError.value = findMinecraftAuthError(rawError.value) matchedError.value = findMinecraftAuthError(rawError.value)
debugCollapsed.value = true debugCollapsed.value = true
hide_ads_window()
modal.value?.show() modal.value?.show()
} }
function hide() { function hide() {
onModalHide()
modal.value?.hide() modal.value?.hide()
} }
function onModalHide() {
show_ads_window()
}
defineExpose({ defineExpose({
show, show,
hide, hide,
@@ -74,7 +67,7 @@ async function copyToClipboard(text: string) {
</script> </script>
<template> <template>
<NewModal ref="modal" header="Sign in Failed" :max-width="'548px'" @hide="onModalHide"> <NewModal ref="modal" header="Sign in Failed" :max-width="'548px'">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<Admonition <Admonition
type="warning" type="warning"
@@ -137,16 +130,18 @@ async function copyToClipboard(text: string) {
<!-- Action buttons --> <!-- Action buttons -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ButtonStyled> <ButtonLink href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()">
<a href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()"> <MessagesSquareIcon /> Contact support
<MessagesSquareIcon /> Contact support </ButtonLink>
</a> <Button
</ButtonStyled> type="colored"
<ButtonStyled color="brand"> color="brand"
<button :disabled="loadingSignIn" class="!w-full" @click="signInAgain"> :disabled="loadingSignIn"
<LogInIcon /> Sign in again class="!w-full"
</button> @click="signInAgain"
</ButtonStyled> >
<LogInIcon /> Sign in again
</Button>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -176,16 +171,15 @@ async function copyToClipboard(text: string) {
> >
{{ debugInfo }} {{ debugInfo }}
</div> </div>
<ButtonStyled circular> <IconButton
<button v-tooltip="'Copy debug info'"
v-tooltip="'Copy debug info'" :label="'Copy debug info'"
:disabled="copied" :disabled="copied"
@click="copyToClipboard(debugInfo)" @click="copyToClipboard(debugInfo)"
> >
<template v-if="copied"> <CheckIcon class="text-green" /> </template> <template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template> <template v-else> <CopyIcon /> </template>
</button> </IconButton>
</ButtonStyled>
</div> </div>
</Collapsible> </Collapsible>
</div> </div>
@@ -20,39 +20,28 @@
</div> </div>
<div class="flex flex-col gap-6 px-6 pb-6"> <div class="flex flex-col gap-6 px-6 pb-6">
<div class="flex justify-end gap-2"> <div class="grid grid-cols-2 gap-2">
<ButtonStyled> <ButtonLink href="https://support.modrinth.com" @click="modal?.hide()">
<a class="w-full !shadow-none" href="https://support.modrinth.com" @click="modal?.hide()"> <MessagesSquareIcon />
<MessagesSquareIcon /> {{ formatMessage(messages.getSupport) }}
{{ formatMessage(messages.getSupport) }} </ButtonLink>
</a> <Button type="colored" color="brand" :disabled="loadingSignIn" @click="signIn">
</ButtonStyled> <SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
<ButtonStyled color="brand"> <svg
<button class="w-full !shadow-none" :disabled="loadingSignIn" @click="signIn"> v-else
<SpinnerIcon v-if="loadingSignIn" class="animate-spin" /> width="20"
<svg height="20"
v-else viewBox="0 0 20 20"
width="20" fill="none"
height="20" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" >
fill="none" <rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
xmlns="http://www.w3.org/2000/svg" <rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
> <rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" /> <rect x="10.75" y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
<rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" /> </svg>
<rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" /> {{ formatMessage(messages.signIn) }}
<rect </Button>
x="10.75"
y="10.75"
width="9.25"
height="9.25"
fill="black"
fill-opacity="0.9"
/>
</svg>
{{ formatMessage(messages.signIn) }}
</button>
</ButtonStyled>
</div> </div>
<p class="m-0 text-center text-sm text-secondary"> <p class="m-0 text-center text-sm text-secondary">
{{ formatMessage(messages.dontHaveAccount) }} {{ formatMessage(messages.dontHaveAccount) }}
@@ -69,7 +58,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets' import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui' import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { inject, type Ref, ref } from 'vue' import { inject, type Ref, ref } from 'vue'
import steveImage from '@/assets/steve-look-up-left.webp' import steveImage from '@/assets/steve-look-up-left.webp'
@@ -6,18 +6,14 @@
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<ButtonStyled type="outlined"> <Button type="outlined" @click="modal?.hide()">
<button @click="modal?.hide()"> <XIcon />
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button type="colored" color="red" @click="confirm">
</ButtonStyled> <TrashIcon />
<ButtonStyled color="red"> {{ formatMessage(messages.deleteButton) }}
<button @click="confirm"> </Button>
<TrashIcon />
{{ formatMessage(messages.deleteButton) }}
</button>
</ButtonStyled>
</div> </div>
</template> </template>
</NewModal> </NewModal>
@@ -27,7 +23,7 @@
import { TrashIcon, XIcon } from '@modrinth/assets' import { TrashIcon, XIcon } from '@modrinth/assets'
import { import {
Admonition, Admonition,
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessages, defineMessages,
NewModal, NewModal,
@@ -3,7 +3,6 @@
ref="modal" ref="modal"
:header="formatMessage(messages.installToPlay)" :header="formatMessage(messages.installToPlay)"
:closable="true" :closable="true"
:on-hide="show_ads_window"
max-width="544px" max-width="544px"
width="544px" width="544px"
> >
@@ -18,12 +17,10 @@
{{ formatMessage(messages.sharedInstance) }} {{ formatMessage(messages.sharedInstance) }}
</span> </span>
<ButtonStyled type="transparent"> <Button type="quiet" @click="openViewContents">
<button @click="openViewContents"> <EyeIcon />
<EyeIcon /> {{ formatMessage(messages.viewContents) }}
{{ formatMessage(messages.viewContents) }} </Button>
</button>
</ButtonStyled>
</div> </div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3"> <div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
@@ -113,65 +110,56 @@
</p> </p>
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
<ButtonStyled type="transparent" color="red"> <Button type="quiet" color="red" @click="handleReport">
<button @click="handleReport"> <ReportIcon />
<ReportIcon /> {{ formatMessage(commonMessages.reportButton) }}
{{ formatMessage(commonMessages.reportButton) }} </Button>
</button>
</ButtonStyled>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<template v-if="hasExternalFiles"> <template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange"> <Button type="quiet" color="orange" @click="handleAccept">
<button @click="handleAccept"> {{ formatMessage(messages.installAnyway) }}
{{ formatMessage(messages.installAnyway) }} </Button>
</button> <Button type="colored" color="brand" @click="handleDecline">
</ButtonStyled> <BanIcon />
<ButtonStyled color="brand"> {{ formatMessage(messages.dontInstall) }}
<button @click="handleDecline"> </Button>
<BanIcon />
{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</template> </template>
<template v-else> <template v-else>
<ButtonStyled> <Button @click="handleDecline">
<button @click="handleDecline"> <XIcon />
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button type="colored" color="brand" @click="handleAccept">
</ButtonStyled> <DownloadIcon />
<ButtonStyled color="brand"> {{ formatMessage(messages.installButton) }}
<button @click="handleAccept"> </Button>
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
</NewModal> </NewModal>
<ModpackContentModal <ManagedContentModal
ref="modpackContentModal" ref="managedContentModal"
:modpack-name="project?.name ?? ''" :header="formatMessage(messages.modpackContent)"
:modpack-icon-url="project?.icon_url ?? undefined" :source-name="project?.name ?? ''"
:source-icon-url="project?.icon_url ?? undefined"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, EyeIcon, ReportIcon, XIcon } from '@modrinth/assets' import { BanIcon, DownloadIcon, EyeIcon, ReportIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { import {
Admonition, Admonition,
Avatar, Avatar,
ButtonStyled,
commonMessages, commonMessages,
type ContentItem, type ContentItem,
defineMessages, defineMessages,
formatLoader, formatLoader,
ModpackContentModal, ManagedContentModal,
NewModal, NewModal,
Table, Table,
type TableColumn, type TableColumn,
@@ -181,7 +169,6 @@ import {
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js' import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { injectServerInstall } from '@/providers/server-install' import { injectServerInstall } from '@/providers/server-install'
@@ -281,10 +268,10 @@ function handleReport() {
} }
} }
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>() const managedContentModal = ref<InstanceType<typeof ManagedContentModal>>()
async function openViewContents() { async function openViewContents() {
modpackContentModal.value?.showLoading() managedContentModal.value?.showLoading()
try { try {
// Ensure version data is available the useQuery may not have resolved yet // Ensure version data is available the useQuery may not have resolved yet
const versionId = modpackVersionId.value const versionId = modpackVersionId.value
@@ -342,10 +329,10 @@ async function openViewContents() {
} }
}, },
) )
modpackContentModal.value?.show(contentItems) managedContentModal.value?.show(contentItems)
} catch (err) { } catch (err) {
console.error('Failed to load modpack contents:', err) console.error('Failed to load modpack contents:', err)
modpackContentModal.value?.show([]) managedContentModal.value?.show([])
} }
} }
@@ -364,7 +351,6 @@ async function show(
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal) if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
hide_ads_window()
modal.value?.show(e) modal.value?.show(e)
await nextTick() await nextTick()
forceCheckTableScroll() forceCheckTableScroll()
@@ -375,6 +361,10 @@ function hide() {
} }
const messages = defineMessages({ const messages = defineMessages({
modpackContent: {
id: 'app.modal.install-to-play.managed-content.modpack-header',
defaultMessage: 'Modpack content',
},
installToPlay: { installToPlay: {
id: 'app.modal.install-to-play.header', id: 'app.modal.install-to-play.header',
defaultMessage: 'Install to play', defaultMessage: 'Install to play',
@@ -415,8 +405,7 @@ const messages = defineMessages({
}, },
reviewedFiles: { reviewedFiles: {
id: 'app.modal.install-to-play.reviewed-files', id: 'app.modal.install-to-play.reviewed-files',
defaultMessage: defaultMessage: "Files that aren't published to Modrinth aren't reviewed.",
'A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack).',
}, },
installAnyway: { installAnyway: {
id: 'app.modal.install-to-play.install-anyway', id: 'app.modal.install-to-play.install-anyway',
@@ -10,24 +10,18 @@
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<ButtonStyled type="outlined"> <Button type="outlined" @click="handleCancel">
<button @click="handleCancel"> <XIcon />
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button @click="handleGoToInstance">
</ButtonStyled> {{ formatMessage(messages.instance) }}
<ButtonStyled> <RightArrowIcon />
<button @click="handleGoToInstance"> </Button>
{{ formatMessage(messages.instance) }} <Button type="colored" color="orange" @click="handleCreateAnyway">
<RightArrowIcon /> <PlusIcon />
</button> {{ formatMessage(messages.create) }}
</ButtonStyled> </Button>
<ButtonStyled color="orange">
<button @click="handleCreateAnyway">
<PlusIcon />
{{ formatMessage(messages.create) }}
</button>
</ButtonStyled>
</div> </div>
</template> </template>
</NewModal> </NewModal>
@@ -36,7 +30,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets' import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import { import {
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessages, defineMessages,
IntlFormatted, IntlFormatted,
@@ -19,18 +19,20 @@
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled> <Button class="w-full" native-type="button" @click="authenticate('sign-up')">
<button class="w-full !shadow-none" type="button" @click="authenticate('sign-up')"> <UserPlusIcon aria-hidden="true" />
<UserPlusIcon aria-hidden="true" /> {{ formatMessage(messages.createAccountButton) }}
{{ formatMessage(messages.createAccountButton) }} </Button>
</button> <Button
</ButtonStyled> type="colored"
<ButtonStyled color="brand"> color="brand"
<button class="w-full" type="button" @click="authenticate('sign-in')"> class="w-full"
<LogInIcon aria-hidden="true" /> native-type="button"
{{ formatMessage(messages.signInButton) }} @click="authenticate('sign-in')"
</button> >
</ButtonStyled> <LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</Button>
</div> </div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary"> <p class="m-0 text-center text-base font-medium leading-6 text-primary">
@@ -69,23 +71,19 @@
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled type="outlined"> <Button type="outlined" class="w-full" native-type="button" @click="modal?.hide()">
<button class="w-full" type="button" @click="modal?.hide()"> <XIcon aria-hidden="true" />
<XIcon aria-hidden="true" /> {{ formatMessage(commonMessages.cancelButton) }}
{{ formatMessage(messages.cancelButton) }} </Button>
</button> <Button
</ButtonStyled> class="w-full"
<ButtonStyled> native-type="button"
<button :disabled="reopeningBrowser"
class="w-full !shadow-none" @click="reopenBrowser"
type="button" >
:disabled="reopeningBrowser" <RefreshCwIcon aria-hidden="true" />
@click="reopenBrowser" {{ formatMessage(messages.openBrowserAgainButton) }}
> </Button>
<RefreshCwIcon aria-hidden="true" />
{{ formatMessage(messages.openBrowserAgainButton) }}
</button>
</ButtonStyled>
</div> </div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary"> <p class="m-0 text-center text-base font-medium leading-6 text-primary">
@@ -108,7 +106,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { LogInIcon, RefreshCwIcon, SpinnerIcon, UserPlusIcon, XIcon } from '@modrinth/assets' import { LogInIcon, RefreshCwIcon, SpinnerIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui' import {
Button,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue' import { ref } from 'vue'
@@ -247,10 +252,6 @@ const messages = defineMessages({
id: 'modal.modrinth-account-required.waiting-for-browser', id: 'modal.modrinth-account-required.waiting-for-browser',
defaultMessage: 'Waiting for browser confirmation...', defaultMessage: 'Waiting for browser confirmation...',
}, },
cancelButton: {
id: 'modal.modrinth-account-required.cancel-button',
defaultMessage: 'Cancel',
},
openBrowserAgainButton: { openBrowserAgainButton: {
id: 'modal.modrinth-account-required.open-browser-again-button', id: 'modal.modrinth-account-required.open-browser-again-button',
defaultMessage: 'Open browser again', defaultMessage: 'Open browser again',
@@ -34,6 +34,7 @@ import { get_project_many, get_version, get_version_many } from '@/helpers/cache
import { wait_for_install_job } from '@/helpers/install' import { wait_for_install_job } from '@/helpers/install'
import { update_managed_modrinth_version } from '@/helpers/instance' import { update_managed_modrinth_version } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { injectAppEvents } from '@/providers/app-events'
import { injectServerInstall } from '@/providers/server-install' import { injectServerInstall } from '@/providers/server-install'
type Dependency = Labrinth.Versions.v3.Dependency type Dependency = Labrinth.Versions.v3.Dependency
@@ -74,6 +75,7 @@ type ProjectInfo = {
} }
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const appEvents = injectAppEvents()
const { startInstallingServer, stopInstallingServer } = injectServerInstall() const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void> type UpdateCompleteCallback = () => void | Promise<void>
@@ -253,7 +255,7 @@ async function handleUpdate() {
try { try {
if (modpackVersionId.value && instance.value) { if (modpackVersionId.value && instance.value) {
const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value) const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value)
await wait_for_install_job(job.job_id) await wait_for_install_job(appEvents, job.job_id)
await onUpdateComplete.value() await onUpdateComplete.value()
} }
} catch (error) { } catch (error) {
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Settings2Icon } from '@modrinth/assets' import { Settings2Icon } from '@modrinth/assets'
import { import {
ButtonStyled, Button,
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
injectPageContext, injectPageContext,
@@ -78,12 +78,10 @@ watch(
{{ formatMessage(messages.adsConsentTitle) }} {{ formatMessage(messages.adsConsentTitle) }}
</h2> </h2>
<div class="mt-2 flex flex-col gap-2.5 items-start"> <div class="mt-2 flex flex-col gap-2.5 items-start">
<ButtonStyled> <Button @click="manageAdsPreferences">
<button class="!shadow-none" @click="manageAdsPreferences"> <Settings2Icon aria-hidden="true" />
<Settings2Icon aria-hidden="true" /> {{ formatMessage(messages.adsConsentManage) }}
{{ formatMessage(messages.adsConsentManage) }} </Button>
</button>
</ButtonStyled>
<div> <div>
{{ formatMessage(messages.adsConsentIntro) }} {{ formatMessage(messages.adsConsentIntro) }}
</div> </div>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled, Toggle } from '@modrinth/ui' import { Button, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -33,14 +33,13 @@ watch(
</h2> </h2>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ButtonStyled type="transparent"> <Button
<button type="quiet"
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]" :disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])" @click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
> >
Reset to default Reset to default
</button> </Button>
</ButtonStyled>
<Toggle <Toggle
id="advanced-rendering" id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)" :model-value="themeStore.getFeatureFlag(option)"
@@ -116,6 +116,35 @@ const messages = defineMessages({
id: 'app.settings.default-instance-options.post-exit-hook.description', id: 'app.settings.default-instance-options.post-exit-hook.description',
defaultMessage: 'Runs after the game closes.', defaultMessage: 'Runs after the game closes.',
}, },
hookVariablesDescription: {
id: 'instance.settings.tabs.hooks.variables.description',
defaultMessage:
'Hooks run in the working directory of the instance, with the following variables:',
},
instanceNameDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-name.description',
defaultMessage: '$INST_NAME: The name of the instance',
},
instanceIdDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-id.description',
defaultMessage: "$INST_ID: The name of the instance's folder",
},
instanceDirDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-dir.description',
defaultMessage: "$INST_DIR: The absolute path to the instance's folder",
},
instanceMcDirDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-mc-dir.description',
defaultMessage: '$INST_MC_DIR: An alias for $INST_DIR',
},
instanceJavaDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-java.description',
defaultMessage: '$INST_JAVA: The absolute path to the java binary',
},
instanceJavaArgsDescription: {
id: 'instance.settings.tabs.hooks.variables.inst-java-args.description',
defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game',
},
}) })
const fetchSettings = await get() const fetchSettings = await get()
@@ -328,6 +357,18 @@ watch(
{{ formatMessage(messages.postExitHookDescription) }} {{ formatMessage(messages.postExitHookDescription) }}
</p> </p>
</div> </div>
<div class="m-0 leading-tight">
{{ formatMessage(messages.hookVariablesDescription) }}
<ul>
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
</ul>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -1,8 +1,9 @@
<script setup> <script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets' import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { import {
ButtonStyled, Button,
defineMessages, defineMessages,
IconButton,
injectNotificationManager, injectNotificationManager,
Slider, Slider,
StyledInput, StyledInput,
@@ -183,16 +184,14 @@ async function findLauncherDir() {
wrapper-class="w-full" wrapper-class="w-full"
> >
<template #right> <template #right>
<ButtonStyled circular> <IconButton
<button v-tooltip="formatMessage(messages.browseAppDirectory)"
v-tooltip="formatMessage(messages.browseAppDirectory)" :label="formatMessage(messages.browseAppDirectory)"
:aria-label="formatMessage(messages.browseAppDirectory)" class="ml-1.5"
class="ml-1.5" @click="findLauncherDir"
@click="findLauncherDir" >
> <FolderSearchIcon aria-hidden="true" />
<FolderSearchIcon aria-hidden="true" /> </IconButton>
</button>
</ButtonStyled>
</template> </template>
</StyledInput> </StyledInput>
<p class="m-0 leading-tight text-secondary"> <p class="m-0 leading-tight text-secondary">
@@ -235,10 +234,10 @@ async function findLauncherDir() {
<h2 class="m-0 text-lg font-semibold text-contrast"> <h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appCacheTitle) }} {{ formatMessage(messages.appCacheTitle) }}
</h2> </h2>
<button id="purge-cache" class="btn min-w-max" @click="handlePurgeCacheClick"> <Button id="purge-cache" class="w-fit" @click="handlePurgeCacheClick">
<TrashIcon aria-hidden="true" /> <TrashIcon aria-hidden="true" />
{{ formatMessage(messages.purgeCache) }} {{ formatMessage(messages.purgeCache) }}
</button> </Button>
<p class="m-0 leading-tight text-secondary"> <p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appCacheDescription) }} {{ formatMessage(messages.appCacheDescription) }}
</p> </p>
@@ -280,10 +279,10 @@ async function findLauncherDir() {
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast"> <h2 class="mt-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.appDatabaseBackupsTitle) }} {{ formatMessage(messages.appDatabaseBackupsTitle) }}
</h2> </h2>
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder"> <Button id="open-db-backups-folder" class="w-fit" @click="openDbBackupsFolder">
<FolderOpenIcon aria-hidden="true" /> <FolderOpenIcon aria-hidden="true" />
{{ formatMessage(messages.openBackupsFolder) }} {{ formatMessage(messages.openBackupsFolder) }}
</button> </Button>
<p class="m-0 leading-tight text-secondary"> <p class="m-0 leading-tight text-secondary">
{{ formatMessage(messages.appDatabaseBackupsDescription) }} {{ formatMessage(messages.appDatabaseBackupsDescription) }}
</p> </p>
@@ -10,24 +10,18 @@
<template #actions> <template #actions>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<ButtonStyled type="outlined"> <Button type="outlined" @click="handleCancel">
<button @click="handleCancel"> <XIcon />
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button @click="handleGoToInstance">
</ButtonStyled> {{ formatMessage(messages.instance) }}
<ButtonStyled> <RightArrowIcon />
<button @click="handleGoToInstance"> </Button>
{{ formatMessage(messages.instance) }} <Button type="colored" color="orange" @click="handleInstallAnyway">
<RightArrowIcon /> <DownloadIcon />
</button> {{ formatMessage(messages.installAnyway) }}
</ButtonStyled> </Button>
<ButtonStyled color="orange">
<button @click="handleInstallAnyway">
<DownloadIcon />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
</div> </div>
</template> </template>
</NewModal> </NewModal>
@@ -36,7 +30,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { DownloadIcon, RightArrowIcon, XIcon } from '@modrinth/assets' import { DownloadIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import { import {
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessages, defineMessages,
IntlFormatted, IntlFormatted,
@@ -26,12 +26,14 @@
</span> </span>
</template> </template>
<div class="flex min-w-0 flex-col gap-3 pt-4"> <div class="flex min-w-0 flex-col gap-3 pt-4">
<div class="max-h-[292px] overflow-y-auto rounded-[20px]"> <div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect <FileTreeSelect
v-model="selectedConfigPaths" v-model="includedConfigPaths"
v-model:excluded-paths="excludedConfigPaths"
:items="configFileItems" :items="configFileItems"
:show-size="false" :show-size="false"
:show-modified="false" :show-modified="false"
@navigate="scrollConfigFileTreeToTop"
/> />
</div> </div>
</div> </div>
@@ -75,13 +77,23 @@ const emit = defineEmits<{
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors() const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>() const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const configFileTreeContainer = ref<HTMLElement>()
const publishDiffs = ref<ContentDiffItem[]>([]) const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([]) const configFilePaths = ref<string[]>([])
const selectedConfigPaths = ref<string[]>([]) const includedConfigPaths = ref<string[]>([])
const excludedConfigPaths = ref<string[]>([])
const state = ref<SharedInstancePublishState>('idle') const state = ref<SharedInstancePublishState>('idle')
const configFileItems = computed<FileTreeSelectItem[]>(() => const configFileItems = computed<FileTreeSelectItem[]>(() =>
configFilePaths.value.map((path) => ({ path, type: 'file' })), configFilePaths.value.map((path) => ({ path, type: 'file' })),
) )
const selectedConfigPaths = computed(() => {
const includedPaths = new Set(includedConfigPaths.value)
const excludedPaths = new Set(excludedConfigPaths.value)
return configFilePaths.value.filter((path) =>
isConfigPathSelected(path, includedPaths, excludedPaths),
)
})
async function show(e?: MouseEvent) { async function show(e?: MouseEvent) {
if (state.value !== 'idle') return if (state.value !== 'idle') return
@@ -106,7 +118,8 @@ async function show(e?: MouseEvent) {
disabled: diff.disabled, disabled: diff.disabled,
})) }))
configFilePaths.value = preview.configFiles configFilePaths.value = preview.configFiles
selectedConfigPaths.value = [] includedConfigPaths.value = []
excludedConfigPaths.value = []
if (!publishReviewModal.value) return if (!publishReviewModal.value) return
publishReviewModal.value.show(e) publishReviewModal.value.show(e)
@@ -132,6 +145,29 @@ async function publishChanges() {
} }
} }
function isConfigPathSelected(
path: string,
includedPaths: Set<string>,
excludedPaths: Set<string>,
) {
let selected = false
let prefix = ''
for (const segment of path.split('/').filter(Boolean)) {
prefix = prefix ? `${prefix}/${segment}` : segment
if (includedPaths.has(prefix)) selected = true
if (excludedPaths.has(prefix)) selected = false
}
return selected
}
function scrollConfigFileTreeToTop() {
if (configFileTreeContainer.value) {
configFileTreeContainer.value.scrollTop = 0
}
}
function finishReview() { function finishReview() {
if (state.value === 'reviewing') { if (state.value === 'reviewing') {
setState('idle') setState('idle')
@@ -39,6 +39,7 @@ import {
} from '@/helpers/install' } from '@/helpers/install'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors' import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { injectAppEvents } from '@/providers/app-events'
type UpdateCompleteCallback = () => void | Promise<void> type UpdateCompleteCallback = () => void | Promise<void>
@@ -54,6 +55,7 @@ const instance = ref<GameInstance | null>(null)
const preview = ref<SharedInstanceUpdatePreview | null>(null) const preview = ref<SharedInstanceUpdatePreview | null>(null)
const onComplete = ref<UpdateCompleteCallback>(() => {}) const onComplete = ref<UpdateCompleteCallback>(() => {})
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const appEvents = injectAppEvents()
const { notifySharedInstanceError } = useSharedInstanceErrors() const { notifySharedInstanceError } = useSharedInstanceErrors()
const diffs = computed<ContentDiffItem[]>( const diffs = computed<ContentDiffItem[]>(
() => () =>
@@ -78,7 +80,7 @@ async function update() {
try { try {
if (instance.value) { if (instance.value) {
const job = await install_update_shared_instance(instance.value.id) const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id) await wait_for_install_job(appEvents, job.job_id)
await onComplete.value() await onComplete.value()
successful = true successful = true
} }
@@ -193,84 +193,71 @@
{{ formatMessage(messages.reviewedFiles) }} {{ formatMessage(messages.reviewedFiles) }}
</p> </p>
<div v-if="!reportMode" class="flex w-full items-center justify-between gap-2"> <div v-if="!reportMode" class="flex w-full items-center justify-between gap-2">
<ButtonStyled color="red" type="transparent"> <Button type="quiet" color="red" @click="reportMode = true">
<button @click="reportMode = true"> <ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }} </Button>
</button>
</ButtonStyled>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<template v-if="hasExternalFiles"> <template v-if="hasExternalFiles">
<ButtonStyled type="transparent" color="orange"> <Button type="quiet" color="orange" @click="accept">
<button @click="accept"> {{ formatMessage(messages.installAnyway) }}
{{ formatMessage(messages.installAnyway) }} </Button>
</button> <Button type="colored" color="brand" @click="handleCancel">
</ButtonStyled> <BanIcon />{{ formatMessage(messages.dontInstall) }}
<ButtonStyled color="brand"> </Button>
<button @click="handleCancel">
<BanIcon />{{ formatMessage(messages.dontInstall) }}
</button>
</ButtonStyled>
</template> </template>
<template v-else> <template v-else>
<ButtonStyled type="outlined"> <Button type="outlined" class="!border" @click="handleCancel">
<button class="!border" @click="handleCancel"> <XIcon />{{ formatMessage(commonMessages.cancelButton) }}
<XIcon />{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button type="colored" color="brand" @click="accept">
</ButtonStyled> <DownloadIcon />{{ formatMessage(messages.installButton) }}
<ButtonStyled color="brand"> </Button>
<button @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
<template v-if="reportMode" #actions> <template v-if="reportMode" #actions>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<ButtonStyled type="outlined"> <Button type="outlined" class="!border" :disabled="submitLoading" @click="handleCancel">
<button class="!border" :disabled="submitLoading" @click="handleCancel"> <XIcon />{{ formatMessage(commonMessages.cancelButton) }}
<XIcon />{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button type="colored" color="brand" :disabled="!canSubmitReport" @click="submitReport">
</ButtonStyled> <SpinnerIcon v-if="submitLoading" class="animate-spin" />
<ButtonStyled color="brand"> <SendIcon v-else />
<button :disabled="!canSubmitReport" @click="submitReport"> {{ formatMessage(commonMessages.reportButton) }}
<SpinnerIcon v-if="submitLoading" class="animate-spin" /> </Button>
<SendIcon v-else />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
</div> </div>
</template> </template>
</NewModal> </NewModal>
<ModpackContentModal <ManagedContentModal
ref="contentModal" ref="contentModal"
:header="formatMessage(messages.sharedInstanceContent)" :header="formatMessage(messages.sharedInstanceContent)"
:modpack-name="preview?.name ?? ''" :source-name="preview?.name ?? ''"
:modpack-icon-url="preview?.iconUrl ?? undefined" :source-icon-url="preview?.iconUrl ?? undefined"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from '@modrinth/assets' import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { import {
Admonition, Admonition,
AutoLink, AutoLink,
Avatar, Avatar,
blockedUsersQueryKey, blockedUsersQueryKey,
ButtonStyled,
Checkbox, Checkbox,
Combobox, Combobox,
type ComboboxOption, type ComboboxOption,
commonMessages, commonMessages,
defineMessages, defineMessages,
formatReportType,
injectAuth, injectAuth,
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
IntlFormatted, IntlFormatted,
ManagedContentModal,
MarkdownEditor, MarkdownEditor,
ModpackContentModal,
NewModal, NewModal,
Table, Table,
type TableColumn, type TableColumn,
@@ -282,7 +269,6 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { config } from '@/config' import { config } from '@/config'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { toError } from '@/helpers/errors' import { toError } from '@/helpers/errors'
import type { SharedInstanceInstallPreview } from '@/helpers/install' import type { SharedInstanceInstallPreview } from '@/helpers/install'
import { create_report } from '@/helpers/reports' import { create_report } from '@/helpers/reports'
@@ -303,7 +289,7 @@ type SharedInstanceCreator = {
} }
const modal = ref<InstanceType<typeof NewModal>>() const modal = ref<InstanceType<typeof NewModal>>()
const contentModal = ref<InstanceType<typeof ModpackContentModal>>() const contentModal = ref<InstanceType<typeof ManagedContentModal>>()
const externalFileTable = ref<HTMLElement | null>(null) const externalFileTable = ref<HTMLElement | null>(null)
const preview = ref<SharedInstanceInstallPreview | null>(null) const preview = ref<SharedInstanceInstallPreview | null>(null)
const creator = ref<SharedInstanceCreator | null>(null) const creator = ref<SharedInstanceCreator | null>(null)
@@ -343,9 +329,9 @@ const externalFileRows = computed<ExternalFileRow[]>(() =>
.sort((left, right) => left.name.localeCompare(right.name)), .sort((left, right) => left.name.localeCompare(right.name)),
) )
const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [ const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [
{ value: 'malicious', label: formatMessage(messages.maliciousReason) }, { value: 'malicious', label: formatReportType(formatMessage, 'malicious') },
{ value: 'inappropriate', label: formatMessage(messages.inappropriateReason) }, { value: 'inappropriate', label: formatReportType(formatMessage, 'inappropriate') },
{ value: 'spam', label: formatMessage(messages.spamReason) }, { value: 'spam', label: formatReportType(formatMessage, 'spam') },
]) ])
const canSubmitReport = computed( const canSubmitReport = computed(
() => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value, () => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value,
@@ -462,7 +448,6 @@ function handleCancel() {
function handleHide() { function handleHide() {
resetReportState() resetReportState()
creator.value = null creator.value = null
show_ads_window()
} }
function resetReportState() { function resetReportState() {
reportMode.value = false reportMode.value = false
@@ -502,7 +487,6 @@ function showReport(
} }
function showPreview(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) { function showPreview(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
preview.value = previewValue preview.value = previewValue
hide_ads_window()
modal.value?.show(event) modal.value?.show(event)
void nextTick(() => forceCheckTableScroll()) void nextTick(() => forceCheckTableScroll())
} }
@@ -557,18 +541,6 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.report-reason', id: 'app.modal.install-to-play.report-reason',
defaultMessage: 'Which rule does this instance violate?', defaultMessage: 'Which rule does this instance violate?',
}, },
maliciousReason: {
id: 'app.modal.install-to-play.report-reason.malicious',
defaultMessage: 'Malicious',
},
inappropriateReason: {
id: 'app.modal.install-to-play.report-reason.inappropriate',
defaultMessage: 'Inappropriate',
},
spamReason: {
id: 'app.modal.install-to-play.report-reason.spam',
defaultMessage: 'Spam',
},
additionalContext: { additionalContext: {
id: 'app.modal.install-to-play.additional-context', id: 'app.modal.install-to-play.additional-context',
defaultMessage: 'Additional context', defaultMessage: 'Additional context',
@@ -612,8 +584,7 @@ const messages = defineMessages({
}, },
reviewedFiles: { reviewedFiles: {
id: 'app.modal.install-to-play.reviewed-files', id: 'app.modal.install-to-play.reviewed-files',
defaultMessage: defaultMessage: "Files that aren't published to Modrinth aren't reviewed.",
'A file is only reviewed if its published to Modrinth, regardless of its file format (including .mrpack).',
}, },
installAnyway: { installAnyway: {
id: 'app.modal.install-to-play.install-anyway', id: 'app.modal.install-to-play.install-anyway',
@@ -4,12 +4,10 @@
<span class="font-semibold text-contrast">{{ <span class="font-semibold text-contrast">{{
heading ?? formatMessage(messages.sharedInstance) heading ?? formatMessage(messages.sharedInstance)
}}</span> }}</span>
<ButtonStyled type="transparent"> <Button type="quiet" @click="emit('viewContents')">
<button @click="emit('viewContents')"> <EyeIcon />
<EyeIcon /> {{ formatMessage(messages.viewContents) }}
{{ formatMessage(messages.viewContents) }} </Button>
</button>
</ButtonStyled>
</div> </div>
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3"> <div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
<Avatar <Avatar
@@ -34,7 +32,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { EyeIcon } from '@modrinth/assets' import { EyeIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, defineMessages, formatLoader, useVIntl } from '@modrinth/ui' import { Avatar, Button, defineMessages, formatLoader, useVIntl } from '@modrinth/ui'
import { computed, toRefs } from 'vue' import { computed, toRefs } from 'vue'
import type { SharedInstanceInstallPreview } from '@/helpers/install' import type { SharedInstanceInstallPreview } from '@/helpers/install'
@@ -22,11 +22,9 @@
<div class="flex flex-col gap-4 w-full min-h-[20rem]"> <div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section v-if="mode === 'edit' && canEditTextureAndModel"> <section v-if="mode === 'edit' && canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2> <h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
<ButtonStyled> <Button @click="openTextureFileBrowser">
<button class="!shadow-none" @click="openTextureFileBrowser"> <UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }} </Button>
</button>
</ButtonStyled>
<input <input
ref="textureFileInput" ref="textureFileInput"
type="file" type="file"
@@ -112,19 +110,21 @@
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<ButtonStyled type="outlined"> <Button type="outlined" :disabled="isSaving" @click="hide">
<button :disabled="isSaving" @click="hide"> <XIcon />{{ formatMessage(commonMessages.cancelButton) }}
<XIcon />{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button
</ButtonStyled> v-tooltip="saveTooltip"
<ButtonStyled color="brand"> type="colored"
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save"> color="brand"
<SpinnerIcon v-if="isSaving" class="animate-spin" /> :disabled="disableSave || isSaving"
<CheckIcon v-else-if="mode === 'new'" /> @click="save"
<SaveIcon v-else /> >
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }} <SpinnerIcon v-if="isSaving" class="animate-spin" />
</button> <CheckIcon v-else-if="mode === 'new'" />
</ButtonStyled> <SaveIcon v-else />
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
</Button>
</div> </div>
</template> </template>
</NewModal> </NewModal>
@@ -133,7 +133,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets' import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
import { import {
ButtonStyled, Button,
CapeButton, CapeButton,
CapeLikeTextButton, CapeLikeTextButton,
commonMessages, commonMessages,
@@ -2,9 +2,10 @@
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets' import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import { import {
Accordion, Accordion,
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessages, defineMessages,
IconButton,
SkinButton, SkinButton,
SkinLikeTextButton, SkinLikeTextButton,
useScrollViewport, useScrollViewport,
@@ -463,25 +464,26 @@ defineExpose({ getAddSkinButtonElement })
@select="emit('select', skin)" @select="emit('select', skin)"
> >
<template v-if="!readOnly" #overlay-buttons> <template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand"> <Button
<button type="colored"
:aria-label="formatMessage(messages.editSkinButton)" color="brand"
class="pointer-events-auto" :aria-label="formatMessage(messages.editSkinButton)"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)" class="pointer-events-auto"
> @click.stop="(event: MouseEvent) => emit('edit', skin, event)"
<EditIcon /> {{ formatMessage(commonMessages.editButton) }} >
</button> <EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</ButtonStyled> </Button>
<ButtonStyled v-show="!skin.is_equipped" circular color="red"> <IconButton
<button v-show="!skin.is_equipped"
v-tooltip="formatMessage(messages.deleteSkinButton)" v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)" type="colored"
class="!rounded-[100%] pointer-events-auto" color="red"
@click.stop="emit('delete', skin)" :label="formatMessage(messages.deleteSkinButton)"
> class="!rounded-[100%] pointer-events-auto"
<TrashIcon /> @click.stop="emit('delete', skin)"
</button> >
</ButtonStyled> <TrashIcon />
</IconButton>
</template> </template>
</SkinButton> </SkinButton>
</div> </div>
@@ -503,25 +505,26 @@ defineExpose({ getAddSkinButtonElement })
@select="emit('select', skin)" @select="emit('select', skin)"
> >
<template v-if="!readOnly" #overlay-buttons> <template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand"> <Button
<button type="colored"
:aria-label="formatMessage(messages.editSkinButton)" color="brand"
class="pointer-events-auto" :aria-label="formatMessage(messages.editSkinButton)"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)" class="pointer-events-auto"
> @click.stop="(event: MouseEvent) => emit('edit', skin, event)"
<EditIcon /> {{ formatMessage(commonMessages.editButton) }} >
</button> <EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</ButtonStyled> </Button>
<ButtonStyled v-show="!skin.is_equipped" circular color="red"> <IconButton
<button v-show="!skin.is_equipped"
v-tooltip="formatMessage(messages.deleteSkinButton)" v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)" type="colored"
class="!rounded-[100%] pointer-events-auto" color="red"
@click.stop="emit('delete', skin)" :label="formatMessage(messages.deleteSkinButton)"
> class="!rounded-[100%] pointer-events-auto"
<TrashIcon /> @click.stop="emit('delete', skin)"
</button> >
</ButtonStyled> <TrashIcon />
</IconButton>
</template> </template>
</SkinButton> </SkinButton>
</div> </div>
@@ -545,15 +548,15 @@ defineExpose({ getAddSkinButtonElement })
@select="emit('select', skin)" @select="emit('select', skin)"
> >
<template #overlay-buttons> <template #overlay-buttons>
<ButtonStyled color="brand"> <Button
<button type="colored"
:aria-label="formatMessage(messages.editSkinButton)" color="brand"
class="pointer-events-auto" :aria-label="formatMessage(messages.editSkinButton)"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)" class="pointer-events-auto"
> @click.stop="(event: MouseEvent) => emit('edit', skin, event)"
<EditIcon /> {{ formatMessage(commonMessages.editButton) }} >
</button> <EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</ButtonStyled> </Button>
</template> </template>
</SkinButton> </SkinButton>
</div> </div>
@@ -9,11 +9,11 @@ import {
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, Button,
commonMessages, commonMessages,
injectNotificationManager, injectNotificationManager,
OverflowMenu,
SmartClickable, SmartClickable,
TeleportOverflowMenu,
useFormatDateTime, useFormatDateTime,
useRelativeTime, useRelativeTime,
useVIntl, useVIntl,
@@ -21,12 +21,12 @@ import {
import { capitalizeString } from '@modrinth/utils' import { capitalizeString } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { get_project } from '@/helpers/cache' import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
@@ -108,7 +108,7 @@ const stop = async (event: MouseEvent) => {
loading.value = false loading.value = false
} }
const unlistenProcesses = await process_listener(async () => { useAppEvent('process', async () => {
await checkProcess() await checkProcess()
}) })
@@ -121,10 +121,6 @@ const checkProcess = async () => {
onMounted(() => { onMounted(() => {
checkProcess() checkProcess()
}) })
onUnmounted(() => {
unlistenProcesses()
})
</script> </script>
<template> <template>
<SmartClickable> <SmartClickable>
@@ -185,54 +181,53 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events"> <div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled v-if="playing && !loading" color="red"> <Button v-if="playing && !loading" type="colored" color="red" @click="stop">
<button @click="stop"> <StopCircleIcon aria-hidden="true" />
<StopCircleIcon aria-hidden="true" /> {{ formatMessage(commonMessages.stopButton) }}
{{ formatMessage(commonMessages.stopButton) }} </Button>
</button> <Button
</ButtonStyled> v-else
<ButtonStyled v-else> v-tooltip="
<button instance.quarantined
v-tooltip=" ? 'This instance has been locked'
instance.quarantined : playing
? 'This instance has been locked' ? 'Instance is already open'
: playing : null
? 'Instance is already open' "
: null :disabled="instance.quarantined || playing || loading"
" @click="play"
:disabled="instance.quarantined || playing || loading" >
@click="play" <SpinnerIcon v-if="loading" class="animate-spin" />
> <PlayIcon v-else aria-hidden="true" />
<SpinnerIcon v-if="loading" class="animate-spin" /> {{ formatMessage(commonMessages.playButton) }}
<PlayIcon v-else aria-hidden="true" /> </Button>
{{ formatMessage(commonMessages.playButton) }} <TeleportOverflowMenu
</button> type="quiet"
</ButtonStyled> label="More options"
<ButtonStyled circular type="transparent"> :options="[
<OverflowMenu {
:options="[ id: 'open-instance',
{ label: 'View instance',
id: 'open-instance', shown: !!instance.id,
shown: !!instance.id, action: () => router.push(encodeURI(`/instance/${instance.id}`)),
action: () => router.push(encodeURI(`/instance/${instance.id}`)), },
}, {
{ id: 'open-folder',
id: 'open-folder', label: formatMessage(commonMessages.openFolderButton),
action: () => showInstanceInFolder(instance.id), action: () => showInstanceInFolder(instance.id),
}, },
]" ]"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #open-instance> <template #open-instance>
<EyeIcon aria-hidden="true" /> <EyeIcon aria-hidden="true" />
View instance View instance
</template> </template>
<template #open-folder> <template #open-folder>
<FolderOpenIcon aria-hidden="true" /> <FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }} {{ formatMessage(commonMessages.openFolderButton) }}
</template> </template>
</OverflowMenu> </TeleportOverflowMenu>
</ButtonStyled>
</div> </div>
</div> </div>
</SmartClickable> </SmartClickable>
@@ -5,12 +5,12 @@ import { GAME_MODES, injectNotificationManager } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue' import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { instance_listener, process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance' import { kill, run } from '@/helpers/instance'
import { get_all } from '@/helpers/process' import { get_all } from '@/helpers/process'
import { get_game_versions } from '@/helpers/tags' import { get_game_versions } from '@/helpers/tags'
@@ -215,11 +215,11 @@ async function stopInstance(path: string) {
const currentInstance = ref<string>() const currentInstance = ref<string>()
const currentWorld = ref<string>() const currentWorld = ref<string>()
const unlistenProcesses = await process_listener(async () => { useAppEvent('process', async () => {
await checkProcesses() await checkProcesses()
}) })
const unlistenInstances = await instance_listener(async () => { useAppEvent('instance', async () => {
await populateJumpBackIn().catch(() => { await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in') console.error('Failed to populate jump back in')
}) })
@@ -251,11 +251,6 @@ onMounted(() => {
checkProcesses() checkProcesses()
linuxPopulateCount.value = 0 linuxPopulateCount.value = 0
}) })
onUnmounted(() => {
unlistenProcesses()
unlistenInstances()
})
</script> </script>
<template> <template>
@@ -21,13 +21,13 @@ import {
import type { MessageDescriptor } from '@modrinth/ui' import type { MessageDescriptor } from '@modrinth/ui'
import { import {
Avatar, Avatar,
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
OverflowMenu,
SmartClickable, SmartClickable,
TagItem, TagItem,
TeleportOverflowMenu,
useFormatDateTime, useFormatDateTime,
useFormatNumber, useFormatNumber,
useRelativeTime, useRelativeTime,
@@ -412,177 +412,184 @@ const messages = defineMessages({
</template> </template>
</div> </div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events"> <div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<ButtonStyled <Button
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance" v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
type="colored"
color="red" color="red"
@click="emit('stop')"
> >
<button @click="emit('stop')"> <StopCircleIcon aria-hidden="true" />
<StopCircleIcon aria-hidden="true" /> {{ formatMessage(commonMessages.stopButton) }}
{{ formatMessage(commonMessages.stopButton) }} </Button>
</button> <Button
</ButtonStyled> v-else
<ButtonStyled v-else> v-tooltip="
<button quarantined
v-tooltip=" ? 'This instance has been locked'
quarantined : world.type === 'server'
? 'This instance has been locked' ? !supportsServerQuickPlay
: world.type === 'server' ? formatMessage(messages.noServerQuickPlay)
? !supportsServerQuickPlay : playingOtherWorld
? formatMessage(messages.noServerQuickPlay) ? formatMessage(messages.gameAlreadyOpen)
: playingOtherWorld : !serverStatus
? formatMessage(messages.gameAlreadyOpen) ? formatMessage(messages.noContact)
: !serverStatus : serverIncompatible
? formatMessage(messages.noContact) ? formatMessage(messages.incompatibleServer)
: serverIncompatible : null
? formatMessage(messages.incompatibleServer) : !supportsWorldQuickPlay
: null ? formatMessage(messages.noSingleplayerQuickPlay)
: !supportsWorldQuickPlay : playingOtherWorld || locked
? formatMessage(messages.noSingleplayerQuickPlay) ? formatMessage(messages.gameAlreadyOpen)
: playingOtherWorld || locked : null
? formatMessage(messages.gameAlreadyOpen) "
: null :disabled="
" quarantined ||
:disabled=" playingOtherWorld ||
quarantined || startingInstance ||
playingOtherWorld || (world.type == 'server' && !supportsServerQuickPlay) ||
startingInstance || (world.type == 'singleplayer' && !supportsWorldQuickPlay)
(world.type == 'server' && !supportsServerQuickPlay) || "
(world.type == 'singleplayer' && !supportsWorldQuickPlay) @click="emit('play')"
" >
@click="emit('play')" <SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
> <PlayIcon v-else aria-hidden="true" />
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" /> {{ formatMessage(commonMessages.playButton) }}
<PlayIcon v-else aria-hidden="true" /> </Button>
{{ formatMessage(commonMessages.playButton) }} <TeleportOverflowMenu
</button> type="quiet"
</ButtonStyled> label="More options"
<ButtonStyled circular type="transparent"> :options="[
<OverflowMenu {
:options="[ id: 'play-instance',
{ label: formatMessage(messages.playInstance),
id: 'play-instance', shown: !!instanceId,
shown: !!instanceId, disabled: playingInstance || quarantined,
disabled: playingInstance || quarantined, action: () => emit('play-instance'),
action: () => emit('play-instance'), },
{
id: 'open-instance',
label: formatMessage(messages.viewInstance),
shown: !!instanceId,
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
},
{
id: 'refresh',
label: formatMessage(commonMessages.refreshButton),
shown: world.type === 'server',
action: () => emit('refresh'),
},
{
id: 'copy-address',
label: formatMessage(messages.copyAddress),
shown: world.type === 'server',
action: () => copyToClipboard((world as ServerWorld).address),
},
{
id: 'edit',
label: formatMessage(commonMessages.editButton),
action: () => emit('edit'),
shown: !instanceId,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
{
id: 'open-folder',
label: formatMessage(commonMessages.openFolderButton),
shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
},
{
type: 'divider',
shown: !!instanceId,
},
{
id: 'dont-show-on-home',
label: formatMessage(messages.dontShowOnHome),
shown: !!instanceId,
action: () => {
set_world_display_status(
instanceId,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
}, },
{ },
id: 'open-instance', {
shown: !!instanceId, id: 'create-shortcut',
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`), label: formatMessage(messages.createShortcut),
}, shown: !!shortcutInstanceId && !quarantined,
{ action: () => createShortcut(),
id: 'refresh', },
shown: world.type === 'server', {
action: () => emit('refresh'), type: 'divider',
}, shown: !instanceId,
{ },
id: 'copy-address', {
shown: world.type === 'server', id: 'delete',
action: () => copyToClipboard((world as ServerWorld).address), label: formatMessage(
}, world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
{ ),
id: 'edit', tone: 'red',
action: () => emit('edit'), action: () => emit('delete'),
shown: !instanceId, shown: !instanceId,
disabled: locked || managed, disabled: locked || managed,
tooltip: locked tooltip: locked
? formatMessage(messages.worldInUse) ? formatMessage(messages.worldInUse)
: managed : managed
? formatMessage(messages.linkedServer) ? formatMessage(messages.linkedServer)
: undefined, : undefined,
}, },
{ ]"
id: 'open-folder', >
shown: world.type === 'singleplayer', <MoreVerticalIcon aria-hidden="true" />
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}), <template #play-instance>
}, <PlayIcon aria-hidden="true" />
{ {{ formatMessage(messages.playInstance) }}
divider: true, </template>
shown: !!instanceId, <template #open-instance>
}, <EyeIcon aria-hidden="true" />
{ {{ formatMessage(messages.viewInstance) }}
id: 'dont-show-on-home', </template>
shown: !!instanceId, <template #edit>
action: () => { <EditIcon aria-hidden="true" />
set_world_display_status( {{ formatMessage(commonMessages.editButton) }}
instanceId, </template>
world.type, <template #open-folder>
getWorldIdentifier(world), <FolderOpenIcon aria-hidden="true" />
'hidden', {{ formatMessage(commonMessages.openFolderButton) }}
).then(() => { </template>
emit('update') <template #copy-address>
}) <ClipboardCopyIcon aria-hidden="true" />
}, {{ formatMessage(messages.copyAddress) }}
}, </template>
{ <template #refresh>
id: 'create-shortcut', <UpdatedIcon aria-hidden="true" />
shown: !!shortcutInstanceId && !quarantined, {{ formatMessage(commonMessages.refreshButton) }}
action: () => createShortcut(), </template>
}, <template #create-shortcut>
{ <ExternalIcon aria-hidden="true" />
divider: true, {{ formatMessage(messages.createShortcut) }}
shown: !instanceId, </template>
}, <template #dont-show-on-home>
{ <XIcon aria-hidden="true" />
id: 'delete', {{ formatMessage(messages.dontShowOnHome) }}
color: 'red', </template>
hoverFilled: true, <template #delete>
action: () => emit('delete'), <TrashIcon aria-hidden="true" />
shown: !instanceId, {{
disabled: locked || managed, formatMessage(
tooltip: locked world.type === 'server' ? commonMessages.removeButton : commonMessages.deleteLabel,
? formatMessage(messages.worldInUse) )
: managed }}
? formatMessage(messages.linkedServer) </template>
: undefined, </TeleportOverflowMenu>
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #open-instance>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }}
</template>
<template #edit>
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</template>
<template #open-folder>
<FolderOpenIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openFolderButton) }}
</template>
<template #copy-address>
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(messages.copyAddress) }}
</template>
<template #refresh>
<UpdatedIcon aria-hidden="true" />
{{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #create-shortcut>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.createShortcut) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
{{
formatMessage(
world.type === 'server'
? commonMessages.removeButton
: commonMessages.deleteLabel,
)
}}
</template>
</OverflowMenu>
</ButtonStyled>
</div> </div>
</div> </div>
</SmartClickable> </SmartClickable>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets' import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { import {
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
@@ -96,24 +96,18 @@ defineExpose({ show, hide })
/> />
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<ButtonStyled type="outlined"> <Button type="outlined" @click="hide()">
<button @click="hide()"> <XIcon />
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button :disabled="!address" @click="addServer(false)">
</ButtonStyled> <PlusIcon />
<ButtonStyled> {{ formatMessage(messages.addServer) }}
<button :disabled="!address" @click="addServer(false)"> </Button>
<PlusIcon /> <Button type="colored" color="brand" :disabled="!address" @click="addServer(true)">
{{ formatMessage(messages.addServer) }} <PlayIcon />
</button> {{ formatMessage(messages.addAndPlay) }}
</ButtonStyled> </Button>
<ButtonStyled color="brand">
<button :disabled="!address" @click="addServer(true)">
<PlayIcon />
{{ formatMessage(messages.addAndPlay) }}
</button>
</ButtonStyled>
</div> </div>
</template> </template>
</NewModal> </NewModal>
@@ -2,7 +2,7 @@
import { TrashIcon, XIcon } from '@modrinth/assets' import { TrashIcon, XIcon } from '@modrinth/assets'
import { import {
Admonition, Admonition,
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessages, defineMessages,
NewModal, NewModal,
@@ -106,18 +106,19 @@ defineExpose({ show, hide })
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<ButtonStyled type="outlined"> <Button type="outlined" @click="hide">
<button @click="hide"> <XIcon />
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button
</ButtonStyled> type="colored"
<ButtonStyled color="red"> color="red"
<button :disabled="!isServer && !isSingleplayer" @click="confirm"> :disabled="!isServer && !isSingleplayer"
<TrashIcon /> @click="confirm"
{{ formatMessage(actionMessage) }} >
</button> <TrashIcon />
</ButtonStyled> {{ formatMessage(actionMessage) }}
</Button>
</div> </div>
</template> </template>
</NewModal> </NewModal>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets' import { SaveIcon, XIcon } from '@modrinth/assets'
import { import {
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessage, defineMessage,
injectNotificationManager, injectNotificationManager,
@@ -105,18 +105,14 @@ const titleMessage = defineMessage({
<HideFromHomeOption v-model="hideFromHome" class="mt-3" /> <HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<ButtonStyled type="outlined"> <Button type="outlined" @click="hide()">
<button @click="hide()"> <XIcon />
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
{{ formatMessage(commonMessages.cancelButton) }} </Button>
</button> <Button type="colored" color="brand" :disabled="!address" @click="saveServer">
</ButtonStyled> <SaveIcon />
<ButtonStyled color="brand"> {{ formatMessage(commonMessages.saveChangesButton) }}
<button :disabled="!address" @click="saveServer"> </Button>
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
</div> </div>
</template> </template>
</NewModal> </NewModal>
@@ -2,7 +2,7 @@
import { ChevronRightIcon, SaveIcon, UndoIcon, XIcon } from '@modrinth/assets' import { ChevronRightIcon, SaveIcon, UndoIcon, XIcon } from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, Button,
commonMessages, commonMessages,
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
@@ -113,24 +113,18 @@ const messages = defineMessages({
<HideFromHomeOption v-model="hideFromHome" class="mt-3" /> <HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div> </div>
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<ButtonStyled color="brand"> <Button type="colored" color="brand" @click="saveWorld">
<button @click="saveWorld"> <SaveIcon />
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
{{ formatMessage(commonMessages.saveChangesButton) }} </Button>
</button> <Button :disabled="removeIcon || !icon" @click="removeIcon = true">
</ButtonStyled> <UndoIcon />
<ButtonStyled> {{ formatMessage(messages.resetIcon) }}
<button :disabled="removeIcon || !icon" @click="removeIcon = true"> </Button>
<UndoIcon /> <Button @click="hide()">
{{ formatMessage(messages.resetIcon) }} <XIcon />
</button> {{ formatMessage(commonMessages.cancelButton) }}
</ButtonStyled> </Button>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
@@ -10,7 +10,6 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import { install_job_listener } from '@/helpers/events'
import { import {
install_job_dismiss, install_job_dismiss,
install_job_list, install_job_list,
@@ -23,6 +22,7 @@ import {
type InstallProgress, type InstallProgress,
} from '@/helpers/install' } from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance' import { get_many as getInstances } from '@/helpers/instance'
import { injectAppEvents } from '@/providers/app-events'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
const messages = defineMessages({ const messages = defineMessages({
@@ -234,6 +234,7 @@ export async function useInstallJobNotifications(opts: {
handleError: (err: unknown) => void handleError: (err: unknown) => void
onChange: () => void onChange: () => void
}) { }) {
const appEvents = injectAppEvents()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const themeStore = useTheming() const themeStore = useTheming()
const jobs = ref<InstallJobSnapshot[]>([]) const jobs = ref<InstallJobSnapshot[]>([])
@@ -404,8 +405,6 @@ export async function useInstallJobNotifications(opts: {
} }
function getProgress(job: InstallJobSnapshot): number { function getProgress(job: InstallJobSnapshot): number {
if (job.status === 'succeeded') return 1
if (job.status === 'failed' || job.status === 'interrupted') return 0
const progress = getEffectiveProgress(job) const progress = getEffectiveProgress(job)
if (!progress || progress.total <= 0) return 0 if (!progress || progress.total <= 0) return 0
return Math.max(0, Math.min(1, progress.current / progress.total)) return Math.max(0, Math.min(1, progress.current / progress.total))
@@ -526,8 +525,12 @@ export async function useInstallJobNotifications(opts: {
) )
} }
const activeJobs = computed(() =>
jobs.value.filter((job) => job.status === 'queued' || job.status === 'running'),
)
const progressItems = computed<PopupNotificationProgressItem[]>(() => const progressItems = computed<PopupNotificationProgressItem[]>(() =>
jobs.value.map((job) => { activeJobs.value.map((job) => {
const progress = getEffectiveProgress(job) const progress = getEffectiveProgress(job)
return { return {
@@ -536,20 +539,26 @@ export async function useInstallJobNotifications(opts: {
text: getText(job), text: getText(job),
iconUrl: iconUrls.value[job.job_id] ?? null, iconUrl: iconUrls.value[job.job_id] ?? null,
progress: getProgress(job), progress: getProgress(job),
waiting: !job.progress && ['queued', 'running'].includes(job.status), waiting: !job.progress && job.status === 'running',
showProgress: !isTerminalJob(job), showProgress: job.status === 'running',
wrapText: isTerminalJob(job), progressType: getProgressType(job),
progressType: isTerminalJob(job) ? undefined : getProgressType(job), progressCurrent: progress?.current,
progressCurrent: isTerminalJob(job) ? undefined : progress?.current, progressTotal: progress?.total,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getButtons(job), buttons: getButtons(job),
dismissible: isTerminalJob(job),
onDismiss: getDismissHandler(job),
} }
}), }),
) )
const buttons = computed<PopupNotificationButton[] | undefined>(() => undefined) const terminalNotifications = computed(() =>
jobs.value.filter(isTerminalJob).map((job) => ({
id: job.job_id,
title: getTitle(job),
text: getText(job),
type: job.status === 'failed' ? ('error' as const) : ('warning' as const),
buttons: getButtons(job),
onDismiss: getDismissHandler(job),
})),
)
async function refreshMetadata(notify = true) { async function refreshMetadata(notify = true) {
const request = ++metadataRequest const request = ++metadataRequest
@@ -627,14 +636,14 @@ export async function useInstallJobNotifications(opts: {
void refreshMetadata() void refreshMetadata()
} }
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job)) const unlisten = appEvents.on('install_job', applyJobUpdate)
await refresh(false) await refresh(false)
return { return {
active: computed(() => jobs.value.length > 0), active: computed(() => activeJobs.value.length > 0),
title: computed(() => formatMessage(messages.installs)), title: computed(() => formatMessage(messages.installs)),
progressItems, progressItems,
buttons, terminalNotifications,
refresh, refresh,
dispose: () => { dispose: () => {
for (const timeout of copiedResetTimeouts.values()) { for (const timeout of copiedResetTimeouts.values()) {
@@ -12,11 +12,12 @@ import {
fetchCachedServerStatus, fetchCachedServerStatus,
getFreshCachedServerStatus, getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query' } from '@/composables/instances/use-server-status-query'
import { process_listener } from '@/helpers/events' import { useAppEvent } from '@/composables/use-app-event'
import { kill, list as listInstances } from '@/helpers/instance' import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process' import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds' import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
import { instanceKeys } from '@/pages/instance/query-options'
interface BrowseServerInstance { interface BrowseServerInstance {
id: string id: string
@@ -38,7 +39,7 @@ interface ContextMenuOptionClick {
} }
export interface UseAppServerBrowseOptions { export interface UseAppServerBrowseOptions {
instance: Ref<BrowseServerInstance | null> instance: Readonly<Ref<BrowseServerInstance | null>>
isFromWorlds: ComputedRef<boolean> isFromWorlds: ComputedRef<boolean>
allInstalledIds: ComputedRef<Set<string>> allInstalledIds: ComputedRef<Set<string>>
newlyInstalled: Ref<string[]> newlyInstalled: Ref<string[]>
@@ -77,7 +78,6 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([]) const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null) const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingsActive = true let serverPingsActive = true
let unlistenProcesses: (() => void) | null = null
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) { async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('checkServerRunningStates', { hitCount: hits.length }) debugLog('checkServerRunningStates', { hitCount: hits.length })
@@ -131,7 +131,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
project.minecraft_java_server?.content?.kind, project.minecraft_java_server?.content?.kind,
) )
options.newlyInstalled.value.push(project.project_id) options.newlyInstalled.value.push(project.project_id)
await queryClient.invalidateQueries({ queryKey: ['worlds', instanceId] }) await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instanceId) })
} catch (error) { } catch (error) {
options.handleError(error) options.handleError(error)
} }
@@ -279,7 +279,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
} }
} }
process_listener((event: { event: string; instance_id: string }) => { useAppEvent('process', (event) => {
debugLog('process event', event) debugLog('process event', event)
if (event.event === 'finished') { if (event.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find( const projectId = Object.entries(runningServerProjects.value).find(
@@ -291,14 +291,9 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
} }
} }
}) })
.then((unlisten) => {
unlistenProcesses = unlisten
})
.catch(options.handleError)
onUnmounted(() => { onUnmounted(() => {
serverPingsActive = false serverPingsActive = false
unlistenProcesses?.()
}) })
return { return {
@@ -0,0 +1,18 @@
import { onScopeDispose } from 'vue'
import {
type AppEventHandler,
type AppEvents,
type AppEventType,
injectAppEvents,
} from '@/providers/app-events'
export function useAppEvent<Type extends AppEventType>(
type: Type,
handler: AppEventHandler<Type>,
events: AppEvents = injectAppEvents(),
) {
const unsubscribe = events.on(type, handler)
onScopeDispose(unsubscribe)
return unsubscribe
}
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, onUnmounted, toValue } from 'vue' import { computed, type MaybeRefOrGetter, toValue } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { toError } from '@/helpers/errors' import { toError } from '@/helpers/errors'
import { friend_listener } from '@/helpers/events.js'
import { import {
acceptCachedFriend, acceptCachedFriend,
add_friend, add_friend,
@@ -127,13 +127,9 @@ export function useFriends(options: {
) )
} }
let unlisten: (() => void) | undefined useAppEvent('friend', () => {
void friend_listener(() => {
void queryClient.invalidateQueries({ queryKey: queryKey.value }) void queryClient.invalidateQueries({ queryKey: queryKey.value })
}).then((listener) => {
unlisten = listener
}) })
onUnmounted(() => unlisten?.())
return { return {
query, query,
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CommandPayload } from "./CommandPayload";
import type { FriendPayload } from "./FriendPayload";
import type { InstallJobSnapshot } from "./InstallJobSnapshot";
import type { InstanceBulkUpdateProgressPayload } from "./InstanceBulkUpdateProgressPayload";
import type { InstancePayload } from "./InstancePayload";
import type { LoadingPayload } from "./LoadingPayload";
import type { LogPayload } from "./LogPayload";
import type { ProcessPayload } from "./ProcessPayload";
import type { WarningPayload } from "./WarningPayload";
export type AppEvent = { "type": "loading", "payload": LoadingPayload } | { "type": "process", "payload": ProcessPayload } | { "type": "instance", "payload": InstancePayload } | { "type": "instance_bulk_update_progress", "payload": InstanceBulkUpdateProgressPayload } | { "type": "install_job", "payload": InstallJobSnapshot } | { "type": "command", "payload": CommandPayload } | { "type": "warning", "payload": WarningPayload } | { "type": "friend", "payload": FriendPayload } | { "type": "notification", "payload": unknown } | { "type": "log", "payload": LogPayload } | { "type": "ads_consent_required", "payload": boolean };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CommandPayload = { "event": "InstallMod", id: string, } | { "event": "InstallVersion", id: string, } | { "event": "InstallModpack", id: string, } | { "event": "InstallServer", id: string, } | { "event": "LaunchInstance", id: string, server: string | null, singleplayer_world: string | null, } | { "event": "InstallSharedInstanceInvite", invite_id: string, } | { "event": "RunMRPack", path: string, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FriendStatusPayload } from "./FriendStatusPayload";
export type FriendPayload = { "event": "friend_request", from: string, } | { "event": "user_offline", id: string, } | { "event": "status_update", user_status: FriendStatusPayload, } | { "event": "status_sync" };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FriendStatusPayload = { user_id: string, profile_name: string | null, last_update: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ImportLauncherType = "MultiMC" | "PrismLauncher" | "ATLauncher" | "GDLauncher" | "Curseforge" | "Unknown";
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallApiErrorDetails = { error: string, status?: number, method?: string, url?: string, route?: string, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallErrorContext = { operation: string, source_path?: string, target_path?: string, file_path?: string, entry_path?: string, urls: Array<string>, expected_hash?: string, expected_size?: number, project_id?: string, version_id?: string, minecraft_version?: string, loader?: string, java_version?: number, os?: string, arch?: string, };
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InstallApiErrorDetails } from "./InstallApiErrorDetails";
import type { InstallErrorContext } from "./InstallErrorContext";
import type { InstallPhaseId } from "./InstallPhaseId";
import type { SharedInstanceUnavailableReason } from "./SharedInstanceUnavailableReason";
export type InstallErrorView = { code: string, phase?: InstallPhaseId, message: string, reason?: SharedInstanceUnavailableReason, api?: InstallApiErrorDetails, context?: InstallErrorContext, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallJavaStep = "resolving" | "fetching_metadata" | "downloading" | "extracting" | "validating";
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallJobDisplay = { title: string, icon: string | null, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallJobKind = "create_instance" | "create_modpack_instance" | "create_shared_instance" | "import_instance" | "duplicate_instance" | "install_existing_instance" | "install_pack_to_existing_instance" | "update_shared_instance";
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InstallErrorView } from "./InstallErrorView";
import type { InstallJobDisplay } from "./InstallJobDisplay";
import type { InstallJobKind } from "./InstallJobKind";
import type { InstallJobStatus } from "./InstallJobStatus";
import type { InstallPhaseDetails } from "./InstallPhaseDetails";
import type { InstallPhaseId } from "./InstallPhaseId";
import type { InstallProgress } from "./InstallProgress";
import type { InstallTarget } from "./InstallTarget";
export type InstallJobSnapshot = { job_id: string, instance_id: string | null, kind: InstallJobKind, status: InstallJobStatus, target: InstallTarget, phase: InstallPhaseId, progress: InstallProgress | null, details: InstallPhaseDetails, display: InstallJobDisplay | null, error: InstallErrorView | null, rollback_error: InstallErrorView | null, created: string, modified: string, finished: string | null, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InstallJobStatus = "queued" | "running" | "succeeded" | "failed" | "interrupted" | "canceled";

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