Compare commits

...
Author SHA1 Message Date
coolbot100s 7d88cd3a68 Tweak phrasing on servers alert msg 2026-03-01 00:53:55 -08:00
coolbot100s 011e5d8be6 Merge branch 'coolbot/server-project-moderation' of https://github.com/modrinth/code into coolbot/server-project-moderation 2026-02-28 22:17:41 -08:00
coolbot100s ea508eabc3 don't suggest adding a bedrock IP 2026-02-28 22:17:26 -08:00
Truman GaoandGitHub 89f337997d Merge branch 'truman/linked-server-instances' into coolbot/server-project-moderation
Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
2026-02-28 22:09:44 -08:00
tdgao 2e7178a752 update some categories to default collapse 2026-02-28 22:04:25 -08:00
coolbot100s 9fa7398227 Update tags.vue warning for server projects. 2026-02-28 21:46:52 -08:00
coolbot100s dc2dc34043 fixes 2026-02-28 21:32:38 -08:00
coolbot100s 2ba47b25c6 reupload, and status messages + nag tweaks. 2026-02-28 21:31:11 -08:00
coolbot100s 509749c937 support servers' v3 exclusive links 2026-02-28 19:26:51 -08:00
coolbot100s 58dd006e44 Tags nag works with servers now 2026-02-28 19:08:57 -08:00
tdgao 25a6a7b10d fix: get server modpack content wrong link 2026-02-28 18:20:46 -08:00
tdgao 61dd6e86c5 fix: router.push doesn't add history state, use nagivateTo instead 2026-02-28 18:02:26 -08:00
tdgao 0da18cfa86 fix: router.push doesn't add history state, use nagivateTo instead 2026-02-28 17:58:30 -08:00
tdgao fa6ce37446 fix no projectV3 2026-02-28 16:52:24 -08:00
coolbot100s 381a0d210d starting tags + servers msg 2026-02-28 16:19:06 -08:00
coolbot100s 43b6a5231d Merge branch 'truman/linked-server-instances' into coolbot/server-project-moderation 2026-02-28 16:10:42 -08:00
tdgao 30a5a119f0 Merge branch 'truman/linked-server-instances' of github.com:modrinth/code into truman/linked-server-instances 2026-02-28 16:08:36 -08:00
tdgao 8262a22cce feat: add the concept of linked worlds for server instances and keep in sync with server project 2026-02-28 16:08:33 -08:00
aecsocket 667513ee18 skip license check for server projects 2026-02-28 23:57:33 +00:00
aecsocket 8372b8a792 server projects are not marked as modpacks 2026-02-28 23:57:33 +00:00
tdgao a6a53a107a fix showing modpack/loader version for server instance in installation settings 2026-02-28 15:46:27 -08:00
tdgao 7428863965 update instance online players to be client ping 2026-02-28 14:57:52 -08:00
tdgao 3db890e44f revert any handling for handling base linked modpack content for content tab 2026-02-28 14:40:12 -08:00
tdgao 785987e31d fix open in browser 2026-02-28 14:25:15 -08:00
tdgao 2ba34d1c2c Revert "update redirect middleware no longer needs projectV3"
This reverts commit 1289cb5286.
2026-02-28 14:24:51 -08:00
coolbot100s 2a379e9eae Merge branch 'truman/linked-server-instances' into coolbot/server-project-moderation 2026-02-28 14:20:29 -08:00
tdgao 41a073f02f feat: add the mf icons finally 2026-02-28 14:15:29 -08:00
tdgao 30acba861b fix: server tags labels 2026-02-28 13:40:53 -08:00
tdgao 0b547231c8 update comment 2026-02-28 13:34:07 -08:00
tdgao 1289cb5286 update redirect middleware no longer needs projectV3 2026-02-28 13:33:28 -08:00
tdgao 51f3e9e1f6 feat: add server projects to moderation queue 2026-02-28 13:28:48 -08:00
tdgao 44030706be fix: modpack content name truncate in project card 2026-02-28 10:47:57 -07:00
tdgao 4ed4560ba8 hide navtabs if theres only one tab 2026-02-28 10:25:56 -07:00
tdgao 5e015ff702 update tooltip 2026-02-28 10:18:04 -07:00
tdgao 7204735843 update language display 2026-02-28 10:17:33 -07:00
tdgao 592dfae307 fix offline server showing online 2026-02-28 10:15:36 -07:00
tdgao 8ca9406cff add active scale 2026-02-28 10:00:16 -07:00
tdgao 996ce12b6b update categories filters from one group filter card to separate filters cards 2026-02-28 09:59:59 -07:00
tdgao 2d83881bfd add sorting for user installed content to be top of list in content 2026-02-28 09:40:21 -07:00
tdgao e13356c239 hide servers tab when installing content to instance 2026-02-28 09:23:42 -07:00
coolbot100s f3e6376c11 fix, revert private use msg, adjust server & link nags 2026-02-28 05:29:50 -08:00
coolbot100s efcd26f194 Filter buttons by server project type 2026-02-27 23:16:30 -08:00
coolbot100s f9527921c5 Merge branch 'truman/linked-server-instances' into coolbot/server-project-moderation 2026-02-27 22:18:20 -08:00
tdgao da26304e7a update installation settings to handle vanilla server instance case 2026-02-27 19:53:31 -07:00
tdgao 469906a4a2 feat: implement allow editing server instances 2026-02-27 19:29:46 -07:00
tdgao d9c9053a70 implement add server to instance 2026-02-27 19:08:53 -07:00
coolbot100s 50f6225e33 Merge branch 'truman/linked-server-instances' into coolbot/server-project-moderation 2026-02-27 15:20:03 -08:00
tdgao 6322928609 add projectV3 to shouldShow callback 2026-02-27 16:13:31 -07:00
coolbot100s c07d2b08a9 Prevent undefined stage from appearing on servers. 2026-02-27 15:08:15 -08:00
tdgao fc03b07fb6 fix servers tab label in profile page 2026-02-27 15:28:38 -07:00
tdgao a6e5f9b441 feat: implement category and countries negative filters 2026-02-27 15:19:57 -07:00
tdgao 2d1ebb31b7 fix: copy ip missing hover effect 2026-02-27 14:51:31 -07:00
tdgao 10ddd67b73 feat: installation tab updates 2026-02-27 14:48:03 -07:00
tdgao 335eb534fe feat: add linked to modpack project for regular modpack instances 2026-02-27 14:40:47 -07:00
tdgao 4cda6343c2 slight change to color 2026-02-27 14:36:37 -07:00
tdgao bdfe7d4bb6 fix tags location 2026-02-27 14:26:20 -07:00
tdgao 3ada5b10b9 update search input background 2026-02-27 14:24:51 -07:00
tdgao 015fc656df conquer the type errors in Browse.vue 2026-02-27 14:06:26 -07:00
tdgao 9fdea27a83 fix: type errors 2026-02-27 13:42:07 -07:00
tdgao ef55e28157 fix: server refresh search reactivity 2026-02-27 13:33:43 -07:00
tdgao 73373f4c78 fix: use windows.history.replace instead of router.replace due to vue production bug and remove logs 2026-02-27 13:06:44 -07:00
tdgao d65375102f add logging for refreshSearch in browse.vue 2026-02-27 12:44:51 -07:00
aecsocket f276cd7881 Category and search sort fixes 2026-02-27 18:58:15 +00:00
tdgao 1fd108c6ab remove components. in search facet 2026-02-27 11:51:35 -07:00
aecsocket c49baec024 fetch latest server player count from redis instead of search doc 2026-02-27 18:44:20 +00:00
aecsocket 4a410fb6f7 don't require auth token for logging server play 2026-02-27 18:44:20 +00:00
tdgao 874ee462cb fix refresh search causing infinite rerender i.e. maximum call stack size exceeded
watch(route) → watch(() => [route.query.i, route.query.ai, route.path]) (line 102): The deep watch on the entire Vue Router route object was the most likely cause of the stack overflow. Vue Router's route object contains matched records with component definitions and other deeply nested structures. Deep-watching it triggers recursive traversal on every route change (including those from router.replace() inside refreshSearch()). Now it only watches the specific properties that updateInstanceContext() actually needs.

ref → shallowRef for serverHits and serverPings (line 189-190): The v3 search results can be deeply nested objects (minecraft_java_server.ping.data, content, etc.). Using shallowRef prevents Vue from creating deep reactive proxies on these objects, which is consistent with how results already uses shallowRef on line 295.

Re-entrance guard + try/catch on refreshSearch() (line 310): The watcher calls refreshSearch() without awaiting, so state changes during the async execution could trigger the watcher again, causing concurrent calls. The guard prevents overlapping calls, and the try/catch ensures loading.value = false is always reached (fixing the infinite loading).
2026-02-27 11:43:29 -07:00
tdgao 108c3765b4 small fix on external icon 2026-02-27 10:34:24 -07:00
tdgao 4680820ec8 fix updating modpack shouldn't launch right into game 2026-02-27 10:29:39 -07:00
tdgao 737026c077 feat: add handling for "stop" instance state for server project card and page play button 2026-02-27 10:20:09 -07:00
tdgao a84a5e4fae pnpm prepr 2026-02-27 10:01:04 -07:00
tdgao d327fa8f85 fix diffs which have unknown project/filename 2026-02-27 09:46:21 -07:00
tdgao a97be599a2 add in wiki page 2026-02-26 23:47:16 -07:00
tdgao fa1d5131b0 fix: redirect running function instead of passing function 2026-02-26 23:37:50 -07:00
tdgao d29bf1dc72 delete required content page placeholder 2026-02-26 23:34:06 -07:00
tdgao b2969bab51 fix: wrong way to determine offline status 2026-02-26 23:04:40 -07:00
tdgao ef77d8a1ae pass undefined instead of unknown for modpack content modal 2026-02-26 22:31:15 -07:00
tdgao f63e01f03f fix: profile create error app backend
Here's what was happening and the fix:

Root cause: In create.rs:107, profile_create assumed the icon_path parameter was always a local filename relative to the caches directory. It did caches_dir().join(icon) which produced a path like ...\caches\https://staging-cdn.modrinth.com/... — the colons in https:// are illegal in Windows paths (OS error 123).

The frontend's installServerProject and createVanillaInstance in install.js:290 both pass project.icon_url (a full URL) directly as the icon parameter.

Fix: Modified profile_create to detect when the icon parameter is a URL (starts with http:// or https://). When it is, it downloads the icon via fetch(), extracts the filename from the URL path, and passes the downloaded bytes and filename to set_icon() which hashes and caches it properly. The existing local-file path continues to work as before.
2026-02-26 22:19:41 -07:00
tdgao 265f3394be fix: server project showing offline 2026-02-26 22:01:18 -07:00
tdgao 3948449c60 fix search in app frontend 2026-02-26 21:55:13 -07:00
tdgao 6a488567e3 feat: deep linking to open server project page then open install to play 2026-02-26 19:24:21 -07:00
tdgao 9391dc0eab add query params for server search 2026-02-26 19:19:26 -07:00
tdgao f2c3cbc88a add grouped filters for search 2026-02-26 19:10:42 -07:00
tdgao ab1d14d570 ensure gallery banners are filtered in app backend gallery display 2026-02-26 18:45:30 -07:00
tdgao 8ba581bc62 fix error handling and helper text copy 2026-02-26 18:41:50 -07:00
tdgao ea75b769e6 upload server banner as gallery image with title == "__mc_server_banner__" and filter it from frontend gallery 2026-02-26 18:36:16 -07:00
tdgao a5f5868d8f fix: savable.ts to reset currentValues to data() after save 2026-02-26 18:23:03 -07:00
tdgao 66bcaa3f1f fix incorrect project type in projects in dashboard 2026-02-26 18:06:35 -07:00
tdgao 518e3797b6 remove console.log 2026-02-26 17:45:36 -07:00
tdgao fa3c70434c fix unsaved changes popup for tags 2026-02-26 17:45:16 -07:00
tdgao 1c24d16bb6 updates to compatibility card, add download button and display supported versions better 2026-02-26 17:42:01 -07:00
tdgao 2d25e46bc6 fix javaServerPatchaData wiping content field 2026-02-26 17:19:44 -07:00
tdgao d12c59247f fix setting project compatibility resets unsaved changes 2026-02-26 17:14:57 -07:00
tdgao 820c65fc1a move server related settings into own tab 2026-02-26 17:09:22 -07:00
tdgao 08fef0d79f make offline tag red and properly hook up online tag 2026-02-26 16:50:00 -07:00
tdgao 13f5c31a5d polish server info card styles 2026-02-26 16:35:10 -07:00
tdgao 5de8bed458 update ip address entire button to be clickable 2026-02-26 16:30:18 -07:00
tdgao c844eabe51 change modpack to modded type 2026-02-26 16:26:04 -07:00
tdgao a44b032db0 disable link for custom modpack project and show tooltip 2026-02-26 16:24:17 -07:00
tdgao a39082fda7 revert pulsing 2026-02-26 16:08:43 -07:00
tdgao c6288daa9d add online status indicator pulsing 2026-02-26 16:03:26 -07:00
tdgao 85cb988708 update online/offline tag 2026-02-26 15:57:42 -07:00
tdgao b7061b2ac8 reenable fence in app backend 2026-02-26 15:26:12 -07:00
tdgao af56fc0bb3 remove play button in website discovery for servers 2026-02-26 15:18:07 -07:00
tdgao 16f4ecf154 Merge branch 'truman/linked-server-instances' of github.com:modrinth/code into truman/linked-server-instances 2026-02-26 15:15:27 -07:00
tdgao 0d48962904 fix: update compatibility modal not closing after successful upload 2026-02-26 15:15:24 -07:00
aecsocket c63b3dacd2 also default java server content 2026-02-26 19:19:15 +00:00
tdgao b34f7e270e small fix on languages multi select 2026-02-26 11:13:27 -07:00
tdgao 1821249025 Merge branch 'truman/linked-server-instances' of github.com:modrinth/code into truman/linked-server-instances 2026-02-26 11:09:48 -07:00
tdgao 0813e1ff95 feat: show server project tags 2026-02-26 11:09:44 -07:00
aecsocket 6dc428c71f Default deserialize ProjectSerial 2026-02-26 18:03:24 +00:00
tdgao 066a4ac1e4 feat: hook up languages 2026-02-26 11:03:06 -07:00
tdgao 2718eec6c5 fix: allow java and bedrock addresses to be deleted 2026-02-26 10:28:52 -07:00
tdgao bbfac2fb25 fix game version filter facet 2026-02-26 10:23:41 -07:00
tdgao 9deb97f45d Merge branch 'truman/linked-server-instances' of github.com:modrinth/code into truman/linked-server-instances 2026-02-26 10:06:19 -07:00
tdgao e76645fc8e pnpm prepr 2026-02-26 10:06:15 -07:00
tdgao af4e6f9349 feat: implement use server search for search sorting and filter facets 2026-02-26 10:06:10 -07:00
tdgao 98243603f2 update java server ping schema 2026-02-26 09:46:11 -07:00
aecsocket fefb446ada send account UUID along with server play analytics 2026-02-26 16:00:17 +00:00
aecsocket 102a7ad7b7 move reporting server joins to backend 2026-02-26 13:36:02 +00:00
aecsocket 4e64ded09b Add game versions to server filters 2026-02-26 13:14:26 +00:00
aecsocket 4d68a7f793 Don't return missing components in project query 2026-02-26 13:01:07 +00:00
tdgao 2464646483 hook up search v3 in app backend for app frontend 2026-02-25 23:09:21 -07:00
tdgao 468b44f1fc fix: server project card tags 2026-02-25 22:29:42 -07:00
tdgao 2a95beb0aa hook up backend searchfor frontend in websiet 2026-02-25 22:15:41 -07:00
tdgao 2b7fdf0e05 fix a lot of the bugginess of navtabs when theres hidden/shown tabs between instances. match frontend nav tabs 2026-02-25 21:24:33 -07:00
tdgao 90a905be69 fix switching between server instance vs regular instance persisted invalid state 2026-02-25 21:14:43 -07:00
tdgao 72de13d38b fix nav tabs active tag 2026-02-25 21:12:35 -07:00
tdgao b65e0d1a03 copy content card item and table from content tab update branch 2026-02-25 20:54:02 -07:00
tdgao 6a97c6e2d9 fix hiding modal after saving server compatibility version 2026-02-25 20:28:07 -07:00
tdgao f15d5d18af handle locking server instance content 2026-02-25 20:25:46 -07:00
tdgao e7e7dd4057 many small fixes 2026-02-25 19:53:24 -07:00
tdgao 7a6233e1aa fix: remove required content tab from server project page 2026-02-25 19:10:02 -07:00
tdgao 1a5529c500 small fix to keep color consistent 2026-02-25 17:49:25 -07:00
tdgao a311bd2648 feat: add tags sidebar card 2026-02-25 17:46:04 -07:00
tdgao 397b3892c8 feat: add hide label for project cards 2026-02-25 17:31:01 -07:00
tdgao 1e79d771b5 update server project header and project card 2026-02-25 17:18:58 -07:00
tdgao 8be5d2521f feat: update project sidebar server info card to new design 2026-02-25 17:01:57 -07:00
tdgao dd9cacab55 add project sidebar stories 2026-02-25 15:16:14 -07:00
aecsocket dd0fa9bf33 cargo sqlx prepare 2026-02-25 19:57:00 +00:00
tdgao 7331101609 hook up server recent plays 2026-02-25 11:51:34 -07:00
tdgao 1cb52b86c3 Merge branch 'truman/linked-server-instances' of github.com:modrinth/code into truman/linked-server-instances 2026-02-25 11:41:32 -07:00
tdgao cbea77f03f fix: allow for links to be set to empty 2026-02-25 11:41:29 -07:00
aecsocket fe3153344b Fix backend impl for server player count analytics 2026-02-25 18:36:21 +00:00
tdgao ae6d1f6782 fix server projects links title 2026-02-25 11:18:45 -07:00
tdgao 4531705051 fix empty server info card 2026-02-25 11:17:57 -07:00
tdgao fcc098f919 fix server projects showing license 2026-02-25 11:16:18 -07:00
tdgao 5e1a7a8695 Merge branch 'truman/linked-server-instances' of github.com:modrinth/code into truman/linked-server-instances 2026-02-25 10:55:41 -07:00
tdgao 1c5181464a fix: server project detection to do loose null check 2026-02-25 10:55:30 -07:00
aecsocket 2a7916eda6 fixes to tracking server joins 2026-02-25 17:38:54 +00:00
aecsocket 2ceb2ef02d report server plays improved in frontend 2026-02-25 16:30:37 +00:00
aecsocket 8e504e9449 queue up server plays in batches 2026-02-25 16:25:57 +00:00
aecsocket f0d1b61293 server verified plays compiling 2026-02-25 16:16:42 +00:00
aecsocket 9e344e5555 wip: server verified plays 2026-02-25 15:10:52 +00:00
coolbot100s edb0de4ecd moderation checklist groundwork 2026-02-25 04:09:07 -08:00
coolbot100s dc79587a3c first few server related nags 2026-02-25 02:58:52 -08:00
coolbot100s e51b2206ec move add-links nag to links.ts 2026-02-25 01:26:25 -08:00
coolbot100s c347dbd256 filter certain nags out from server projects. 2026-02-25 01:25:48 -08:00
tdgao 97be76d998 add ping to project header 2026-02-24 22:30:32 -07:00
tdgao 9d3bd50522 fix play server project button when instance is launched 2026-02-24 22:28:02 -07:00
tdgao 2c362ca7ba add projectV3 to nag context for moderation package 2026-02-24 22:21:10 -07:00
tdgao e312c77159 implement ping server projects for latency in app 2026-02-24 22:13:37 -07:00
tdgao 57757dbfb1 small change: open discover to modpack 2026-02-24 21:51:31 -07:00
tdgao 1e4dfe9d68 add comment 2026-02-24 21:34:10 -07:00
tdgao 35b7bb3c3c add global installing server project state 2026-02-24 21:30:36 -07:00
tdgao 5ea8a956f1 show update success instead of launching right into the game 2026-02-24 17:46:15 -07:00
tdgao 94c64c28d4 use large play button instance 2026-02-24 17:45:52 -07:00
tdgao 216f28850c fix how install.js installs instance with modpack content title instead of server project title and dont fetch icon when installing to existing instance 2026-02-24 17:26:26 -07:00
tdgao 7a0c7e1992 show recommended & supported versions for vanilla 2026-02-24 16:52:25 -07:00
tdgao 529a455fe3 on click link for server side bar info 2026-02-24 16:43:42 -07:00
tdgao b546bfd5b8 fix undefined property 2026-02-24 16:34:08 -07:00
tdgao adddb72ba7 hide app nav tabs if only one tab 2026-02-24 16:30:36 -07:00
tdgao 62f0a58cac move servers tab to end 2026-02-24 16:29:05 -07:00
tdgao f5387353cc add diff detection for version dependencies without version_id/project_id 2026-02-24 16:21:17 -07:00
tdgao d75d7a6671 Merge branch 'truman/linked-server-instances' of github.com:modrinth/code into truman/linked-server-instances 2026-02-24 14:56:58 -07:00
tdgao 54f2c3a29f fix import 2026-02-24 14:56:42 -07:00
aecsocket a2bec70c62 remove leftover prompt 2026-02-24 18:32:58 +00:00
aecsocket 969700a01f Merge branch 'boris/server-listing-api' into truman/linked-server-instances 2026-02-24 18:24:24 +00:00
aecsocket bdb5d6deac fix search indexing not showing fields 2026-02-24 18:23:46 +00:00
aecsocket 7c3ab22956 fix serde deser flatten 2026-02-24 18:23:46 +00:00
aecsocket 8c68de0ee0 sqlx 2026-02-24 18:23:46 +00:00
aecsocket 46427f6a24 fix more defaults stuff 2026-02-24 18:23:46 +00:00
aecsocket 46bc137a9e wip: massive component query refactor 2026-02-24 18:23:46 +00:00
aecsocket e3cf5c6a0a wip: server modpack content query, components in search 2026-02-24 18:23:46 +00:00
aecsocket d005efa815 fix migration to sync with main, propagate background task errors 2026-02-24 18:23:46 +00:00
aecsocket a42fbb42c1 wip: clean up server ping, background tasks 2026-02-24 18:23:46 +00:00
aecsocket 22e31f9099 synthetic server project version for search indexing 2026-02-24 18:23:46 +00:00
aecsocket 4d64fd175b Improve server messages for app pinging 2026-02-24 18:23:46 +00:00
aecsocket 09290b452d Schema changes 2026-02-24 18:23:46 +00:00
aecsocket c3f76bfd64 clear cache on project ping, add server store link 2026-02-24 18:23:46 +00:00
aecsocket adc85f24bd fix compile error 2026-02-24 18:23:46 +00:00
aecsocket 3c527778a5 tombi fmt 2026-02-24 18:23:46 +00:00
aecsocket 3182c28027 Switch to Redis for server pings 2026-02-24 18:23:46 +00:00
aecsocket 515d1b3196 wip: resolve comments with pings 2026-02-24 18:23:46 +00:00
aecsocket 194873a5e7 fix shear 2026-02-24 18:23:46 +00:00
aecsocket 3acff84465 allow querying server ping data 2026-02-24 18:23:46 +00:00
aecsocket 1455f71f62 tombi fmt 2026-02-24 18:23:46 +00:00
aecsocket 49438d33a1 tombi fmt 2026-02-24 18:23:46 +00:00
aecsocket 6d3139f254 sqlx prepare 2026-02-24 18:23:46 +00:00
aecsocket 3a14e4fd46 mc ping server info + timeout 2026-02-24 18:23:46 +00:00
aecsocket c3a6f4d416 ping queue with tests 2026-02-24 18:23:46 +00:00
aecsocket df7ac297af wip: ping queue 2026-02-24 18:23:46 +00:00
aecsocket f3be9397c9 wip: ping minecraft servers queue 2026-02-24 18:23:46 +00:00
aecsocket 117257bcf9 clean up component edit code 2026-02-24 18:23:46 +00:00
aecsocket 6e011b043f Allow editing components 2026-02-24 18:23:46 +00:00
aecsocket 9aed50d50c docs 2026-02-24 18:23:46 +00:00
aecsocket 4fd747e6aa utoipa support for projects 2026-02-24 18:23:46 +00:00
aecsocket 7ce09d65be game version fields 2026-02-24 18:23:45 +00:00
aecsocket 7cddcee8e1 Version component creation 2026-02-24 18:23:45 +00:00
aecsocket b1c914015b Version components backend API 2026-02-24 18:23:45 +00:00
aecsocket 884907ad0c wip: version components 2026-02-24 18:23:45 +00:00
aecsocket 416598bf6d Update project component structure for servers 2026-02-24 18:23:45 +00:00
aecsocket e04a60e582 New project component metadata schema 2026-02-24 18:23:45 +00:00
aecsocket 4f24641296 No more six seven 2026-02-24 18:23:45 +00:00
aecsocket 55173e9b5a fix up rebase 2026-02-24 18:23:45 +00:00
aecsocket dbacbdafad revert accidental change 2026-02-24 18:23:45 +00:00
aecsocket 487e1e1bdd wip: project components API 2026-02-24 18:23:45 +00:00
aecsocket 7d9e58d877 wip: v67 project creation endpoint 2026-02-24 18:23:45 +00:00
aecsocket 4e5d234701 wip: server listing API 2026-02-24 18:23:45 +00:00
aecsocket 9f814ead27 update frontend to work with new versions list route 2026-02-24 18:23:45 +00:00
tdgao 764926179d use modpack content returned from project 2026-02-24 10:52:04 -07:00
tdgao 2819ade3ce rename server required content to server modpack content 2026-02-24 10:36:44 -07:00
tdgao 955fa43b2d add modpack content link to server project card 2026-02-24 10:34:35 -07:00
tdgao b0b28f90b9 similarly fix routerview race condition between project page and instance page 2026-02-24 09:46:49 -07:00
tdgao 9bacb4594d fix race condition 2026-02-24 09:33:18 -07:00
tdgao 298644dd29 fix updating modpack 2026-02-23 19:51:52 -07:00
tdgao abd3862a65 fix update to play modal requests 2026-02-23 19:48:37 -07:00
tdgao bbadf272e5 small fix on upload custom modpack and add tabular num 2026-02-23 19:31:55 -07:00
tdgao 6f1a06a4d8 remove the massive buttons 2026-02-23 19:19:43 -07:00
tdgao 2694206b35 add server to profile for server projects 2026-02-23 19:09:23 -07:00
tdgao 6b6c49073a add view contents modal 2026-02-23 18:48:35 -07:00
tdgao 0ce315a1ac add dropdown button 2026-02-23 18:48:12 -07:00
tdgao 9195920fca add modpack content modal 2026-02-23 16:54:11 -07:00
tdgao f2f09b9b9c add view contents stub 2026-02-23 16:54:03 -07:00
tdgao c4ebbf0575 hide installation tab for server project instance 2026-02-23 16:31:13 -07:00
tdgao b4b42a42d9 add tooltip on recent plays and online players 2026-02-23 16:21:00 -07:00
tdgao 83714d4660 handle error for join server 2026-02-23 16:02:16 -07:00
tdgao 9182a09453 console log tags 2026-02-23 15:47:42 -07:00
tdgao b5fa03b15e install to play modal show custom mrpack name and remove shared by 2026-02-23 15:04:49 -07:00
tdgao 5b687e6449 fix instance details items 2026-02-23 14:53:34 -07:00
tdgao cd4571f6c7 fix barrel exports 2026-02-23 14:53:16 -07:00
tdgao c1c1fc1b38 fix missing icon for vanilla instance 2026-02-23 14:20:33 -07:00
tdgao 250e163bfb add auto complete "off" for address fields 2026-02-23 13:57:47 -07:00
tdgao c7b70bac10 fix uploading progress label 2026-02-23 13:51:57 -07:00
tdgao 1315052bca add comment 2026-02-23 13:29:23 -07:00
tdgao e7e5e7ecf9 dont default to CA country on create 2026-02-23 13:26:29 -07:00
tdgao f0ee8aacbc add project type field when open create project from org and dashboard 2026-02-23 13:20:52 -07:00
tdgao 038bb53757 fix vanilla server project no tags 2026-02-23 13:20:07 -07:00
tdgao 52e3123c5b fix project type redirect 2026-02-23 12:54:47 -07:00
tdgao c0a2c4d5d4 fixes to server compatibility card 2026-02-23 12:45:42 -07:00
tdgao 89bcd7fb36 disable "create project" when theres missing fields 2026-02-23 12:19:32 -07:00
tdgao f93884ab4b comment out circuit breaker 2026-02-21 18:36:38 -07:00
tdgao 22a348ca64 add option for staging environment in app build 2026-02-21 18:09:13 -07:00
tdgao c135af93a0 add server project hard coded project ids to include the one in search 2026-02-21 17:36:40 -07:00
tdgao c10be2d796 fix get version cache 2026-02-21 17:15:24 -07:00
tdgao 18343eea32 default server discovery flag to true 2026-02-21 17:12:37 -07:00
tdgao a985a43d6c add required content page placeholder in app 2026-02-21 16:45:38 -07:00
tdgao 17e1ade828 add server projects for all 3 types for testing 2026-02-21 16:22:37 -07:00
tdgao 3ff6642544 hook up play button in server card 2026-02-21 15:30:45 -07:00
tdgao 8bd204915d proper fix on nav tabs 2026-02-21 15:15:35 -07:00
tdgao 3eba32bafe fix active tab in nav 2026-02-21 13:56:08 -07:00
tdgao 2025ad5380 small fixes 2026-02-21 13:40:41 -07:00
tdgao 80ea4e1924 add required content tab project page in web and app and placeholder page for web 2026-02-21 13:25:59 -07:00
tdgao 217281e021 add required content component for server details 2026-02-21 13:12:14 -07:00
tdgao 9173505fc5 update open in app modal and hook up to play buttons 2026-02-21 12:45:01 -07:00
tdgao e808ec975c add server project card in search in app 2026-02-20 16:12:23 -07:00
tdgao 7a5d6e94ed add server tab in app 2026-02-20 15:34:53 -07:00
tdgao 7833b1687f move side bar server info queries to parent 2026-02-20 13:58:38 -07:00
tdgao 5cff50bd23 add project sidebar server info to app project page 2026-02-20 13:39:10 -07:00
tdgao c8ace75898 small fix 2026-02-20 13:00:33 -07:00
tdgao c68d5ca86d implement proper server project instance header 2026-02-20 12:58:54 -07:00
tdgao 3c4ef3c6ec add install server project util and fix install and update to play modals which use modpack content 2026-02-20 12:36:16 -07:00
tdgao b52ef5d8ed fix install to play by bypassing cache in app backend install pack 2026-02-20 10:29:16 -07:00
tdgao f1a97e37ff remove hard coded path 2026-02-19 21:47:51 -07:00
tdgao 266f45e37d update install to play with new content api (install not working) 2026-02-19 19:55:08 -07:00
tdgao 2b0ba51c62 update server info card to query required content project 2026-02-19 18:36:24 -07:00
tdgao 0ea4cfed09 small button style update 2026-02-19 18:16:13 -07:00
tdgao 33c9dbeac2 fix compatibility card empty state 2026-02-19 18:06:08 -07:00
tdgao 80e8a61668 style fixes in server compatibility card 2026-02-19 17:55:21 -07:00
tdgao 1b3c8cd0be fix width on floating action bar 2026-02-19 17:45:19 -07:00
tdgao 3fef34c631 remove astrisks 2026-02-19 17:39:56 -07:00
tdgao 40c74dc2cb fix stupid ah type errors 2026-02-19 17:37:01 -07:00
tdgao c3e205f006 remove num followers 2026-02-19 17:33:03 -07:00
tdgao e41665dc87 add sidebar server info card 2026-02-19 17:22:48 -07:00
tdgao b934b79089 fix server link 2026-02-19 16:45:33 -07:00
tdgao 66b6f6eca0 hook up server project discovery to backend 2026-02-19 15:39:35 -07:00
tdgao a2dccf65e2 fix server project hard coded list 2026-02-19 14:52:52 -07:00
tdgao 32008f9580 add redirect for server project type 2026-02-19 14:46:01 -07:00
tdgao 0cb3776520 fix flag svg in project page header 2026-02-19 13:21:39 -07:00
tdgao dd70e834cb remove scroll to top on patch project 2026-02-19 13:05:45 -07:00
tdgao 445c80fbdc Merge branch 'truman/linked-server-instances' into prospector/server-project-page 2026-02-19 13:02:54 -07:00
tdgao fba3c49018 fix imports 2026-02-19 12:49:55 -07:00
tdgao ffc80a4796 use dynamic component for different server vs project page header 2026-02-19 12:35:42 -07:00
tdgao 0d0fdab646 fix: dont circuit break in dev environment 2026-02-19 11:38:33 -07:00
Prospector 1e190ff7c1 Merge remote-tracking branch 'origin/prospector/server-project-page' into prospector/server-project-page
# Conflicts:
#	packages/ui/src/components/project/ServerProjectHeader.vue
#	packages/ui/src/components/project/server/ServerPing.vue
2026-02-19 07:38:18 -08:00
Prospector 19f419fcbb ping to component 2026-02-19 07:37:15 -08:00
Prospector c9571315a4 beginning of server project stuff 2026-02-19 07:35:49 -08:00
Prospector 96700ed2bf remove empty div when modal closed 2026-02-19 07:33:35 -08:00
Prospector 1ad4fdbd93 simplify adplaceholder css 2026-02-19 07:33:35 -08:00
Prospector 73e1fc4709 add more project stuff to api client 2026-02-19 07:33:35 -08:00
Prospector 412f43dac7 images icon 2026-02-19 07:33:35 -08:00
Prospector 15e83bd4e3 avatar ts-ification 2026-02-19 07:33:35 -08:00
Prospector 3fe7679a9e beginning of server project stuff 2026-02-19 07:32:56 -08:00
tdgao 0dcaf6900c Merge branch 'main' into truman/linked-server-instances 2026-02-18 15:21:28 -07:00
tdgao 702e9882b6 fix upload banner 2026-02-18 14:52:09 -07:00
tdgao 6663a3c64f implement exclude own project id in select publish modpack content 2026-02-18 14:49:33 -07:00
tdgao 2ff9db4708 small fix 2026-02-18 14:44:16 -07:00
tdgao fb10ccda52 add new link urls to project page sidebar 2026-02-18 14:42:51 -07:00
tdgao cf637561fe fix v3 patch data 2026-02-18 14:08:20 -07:00
tdgao 41207cbdef remove console.log 2026-02-18 13:46:38 -07:00
tdgao 4d083c0d42 better variable names 2026-02-18 13:46:17 -07:00
tdgao 475010a50d hook up server ping to server project headerr 2026-02-18 13:44:52 -07:00
tdgao 5d48d086a5 add mc server ping to types 2026-02-18 13:04:06 -07:00
tdgao 16c9a3cfce update compatibility card for custom mrpack 2026-02-18 13:03:28 -07:00
tdgao 0e231ca815 add language field 2026-02-18 12:31:12 -07:00
tdgao 599dc90d89 add links todo 2026-02-18 11:39:42 -07:00
tdgao 5635896940 move manage server compatibility modal to providers folder 2026-02-18 10:10:47 -07:00
Prospector 047f72856d remove empty div when modal closed 2026-02-18 07:54:34 -08:00
Prospector 7c5f98033d simplify adplaceholder css 2026-02-18 07:53:40 -08:00
Prospector 78c94e68fa add more project stuff to api client 2026-02-18 07:51:24 -08:00
Prospector 5ade45373a images icon 2026-02-18 07:50:29 -08:00
Prospector 2e2aea2107 avatar ts-ification 2026-02-18 07:50:22 -08:00
tdgao e26df077b0 hook up play server project inside instance play button 2026-02-18 00:06:39 -07:00
tdgao b32d6fd20a refactor: make the play server project function much more readible 2026-02-18 00:01:22 -07:00
tdgao fa02aedfb4 add update vanilla version if changed 2026-02-17 23:50:38 -07:00
tdgao 2ed3984521 small fix 2026-02-17 18:38:32 -07:00
tdgao 3e5a4e795f small fix 2026-02-17 18:29:54 -07:00
tdgao 7772c9ba59 prefill existing values for vanilla 2026-02-17 16:51:27 -07:00
tdgao 055f5525d9 use deep merge instead of shallow merge for project patch mutation 2026-02-17 16:47:32 -07:00
tdgao 1ec00e188f add empty state 2026-02-17 16:35:22 -07:00
tdgao a5ab5bb414 fix cannot read body of undefined 2026-02-17 16:20:49 -07:00
tdgao de60a29c99 use new UnsavedChanges popup in general settings 2026-02-17 16:13:33 -07:00
tdgao b347061fba small style fix 2026-02-17 16:02:17 -07:00
tdgao 436c9c5981 rename showDataLossWarning to isSwitchingCompatibilityType 2026-02-17 15:57:54 -07:00
tdgao 1820fed1cd update show dataloss warning save button 2026-02-17 15:56:51 -07:00
tdgao 3e55aaafef implement danger on switch compatibility type 2026-02-17 15:25:46 -07:00
tdgao 11b2ce1511 small style updates 2026-02-17 15:02:25 -07:00
tdgao b99a460e3e implement proper updating server compatibility 2026-02-17 15:00:50 -07:00
tdgao 1b2b5c859c update required modpack card for custom modpack 2026-02-17 14:15:13 -07:00
tdgao af9c3d1a9d small style updates 2026-02-17 14:08:03 -07:00
tdgao 437734e3bc pnpm prepr 2026-02-17 13:55:28 -07:00
tdgao f664c170f8 delete content tab and create server version modal 2026-02-17 11:25:57 -07:00
tdgao 3f0f60fbea implement upload custom .mrpack 2026-02-17 11:25:36 -07:00
tdgao 406aa672d8 fix usage of --color-text-dark to use --color-text-primary 2026-02-17 11:08:14 -07:00
tdgao fdefb66af0 add compatibility card selected content and move it into its own card in settings 2026-02-17 11:03:33 -07:00
tdgao 90323da310 rename to manage server compatibility modal 2026-02-17 09:54:46 -07:00
tdgao 7f7d30e65f implement save server compatibility 2026-02-16 22:28:27 -07:00
tdgao d4eb66ee6c implement reset modal on hide 2026-02-16 21:36:33 -07:00
tdgao de3ec34aef add tooltip 2026-02-16 21:13:57 -07:00
tdgao c41ba8c3c9 implement select published modpack 2026-02-16 21:11:32 -07:00
tdgao 9ffcbd9b5b implement select compatibility type stage 2026-02-16 20:04:16 -07:00
tdgao e1971365e6 fix compatibility card styles 2026-02-16 18:33:04 -07:00
tdgao c626da819f start server compatibility modal and card 2026-02-16 18:19:09 -07:00
tdgao fbdcd27520 pnpm i 2026-02-16 12:55:46 -07:00
tdgao 8e2833cb2a Merge branch 'main' into truman/linked-server-instances 2026-02-16 12:44:32 -07:00
tdgao a763f6d83a refactor: make the patch data more readiable 2026-02-13 15:36:49 -07:00
tdgao cce25833ff implement setting recommended and supported versions 2026-02-13 15:14:52 -07:00
tdgao 559275bfad small style fixes 2026-02-13 10:52:20 -07:00
tdgao b20465d077 refactor: instances/index to use typescript 2026-02-13 10:38:33 -07:00
tdgao 0b4fcb4e92 fix more cannot get property of... due to null/undefined errors 2026-02-13 09:55:39 -07:00
tdgao 4d9c53dfe0 fix error when loading in app 2026-02-13 09:52:38 -07:00
tdgao 67f9decfe1 fix linked data not passed into profile create 2026-02-12 20:00:04 -07:00
tdgao 0c4bd5b09b fix click to play flow for vanilla servers 2026-02-11 22:08:41 -07:00
tdgao 94bfa26dfd Merge branch 'truman/linked-server-instances' of github.com:modrinth/code into truman/linked-server-instances 2026-02-11 17:50:26 -07:00
tdgao 30a50df85a feat: implement playServerProject util 2026-02-11 17:49:46 -07:00
tdgao e01c2b1a14 get is server project in page 2026-02-11 10:52:18 -07:00
tdgao f231466532 update profile to run instance with server address 2026-02-11 10:51:33 -07:00
Prospector 9ce31b8032 lint 2026-02-11 09:05:56 -08:00
tdgao 192de9e957 implement get_project_v3 in app backend 2026-02-10 17:52:02 -07:00
tdgao dabddacccb use invalidate 2026-02-10 16:52:12 -07:00
tdgao 93d32cf2e9 fix inputs to use styledInput 2026-02-10 16:47:36 -07:00
tdgao 22a961db72 fix patch project v3 2026-02-10 15:58:30 -07:00
tdgao 8bddff40a7 Merge branch 'main' into truman/linked-server-instances 2026-02-10 15:32:53 -07:00
tdgao 6c0f2a985f add tooltip 2026-02-10 15:19:10 -07:00
tdgao c9f7cae4b6 implement new instance header 2026-02-10 14:39:15 -07:00
tdgao c801d155a9 type fix on mc version picker 2026-02-10 14:08:42 -07:00
tdgao e1fa007384 clean up code 2026-02-10 10:55:57 -07:00
tdgao b8e413050c fix data loading in 2026-02-10 10:39:57 -07:00
tdgao 15476975a2 hook up setting server addresses 2026-02-10 10:19:48 -07:00
tdgao 03ac7bcda9 hook in new transfer org 2026-02-09 22:35:28 -07:00
tdgao ec56781ebc various fixes for popup notification 2026-02-09 22:09:04 -07:00
tdgao 168750d45a fix popup notification styles 2026-02-09 22:07:57 -07:00
tdgao 37f63afedb add popup notification panel, add it to storybook, and replace app popups with it 2026-02-08 17:59:21 -07:00
tdgao 9e17cd4d33 pt2 2026-02-08 14:33:44 -07:00
tdgao dc676e15b6 Merge branch 'main' into truman/linked-server-instances 2026-02-08 14:33:37 -07:00
tdgao 0982d2ceb6 better content header for small screen width 2026-02-06 17:58:12 -07:00
tdgao f81e3f36ed move content page header actions slot to be in line with title/summary if there exists a summary 2026-02-06 17:38:42 -07:00
tdgao 17145e303e add server project header 2026-02-06 17:07:40 -07:00
tdgao fd3b562092 hook up update to play 2026-02-06 16:39:34 -07:00
tdgao 8bfb007038 update copy 2026-02-06 16:39:09 -07:00
tdgao 0778e19b0e feat: update project and instance headers to match designs 2026-02-06 16:31:49 -07:00
tdgao 3b39d75685 stub server ping 2026-02-06 14:53:21 -07:00
tdgao 40dc007394 add country field 2026-02-06 11:41:01 -07:00
tdgao f819ce356f fix links page 2026-02-05 16:18:56 -07:00
tdgao 904803c0c0 fix submit when error 2026-02-05 15:38:09 -07:00
tdgao 963d35c133 fix hide on creaet 2026-02-05 15:34:28 -07:00
tdgao 1a6ed679e4 small qa fix 2026-02-05 15:26:00 -07:00
tdgao cb49902b25 fix import 2026-02-05 15:24:14 -07:00
tdgao 062a332247 qa fixes for update to play model 2026-02-05 15:24:06 -07:00
tdgao a8bb03ff1c Merge branch 'main' into truman/linked-server-instances 2026-02-05 13:37:45 -07:00
tdgao a10c242857 update copy 2026-02-05 13:36:56 -07:00
tdgao 407e196c3a hide environment moderation for server project 2026-02-05 13:36:54 -07:00
tdgao f7f31c0991 feat: update visibility section 2026-02-05 13:23:57 -07:00
tdgao 2ad5cd5442 copy updates and reorganizing settings 2026-02-05 12:47:21 -07:00
tdgao cf9ca8b9be fix submit buttons 2026-02-05 12:47:09 -07:00
tdgao d915a32f53 add combobox sublabel 2026-02-05 12:46:44 -07:00
tdgao 66953d851c filter recommended version select from support mc versions 2026-02-05 11:44:23 -07:00
tdgao a9011576af use featured gallary image as banner 2026-02-05 11:38:59 -07:00
tdgao 98eeb9fe13 add gallery routes to api client 2026-02-05 10:56:30 -07:00
tdgao 0c18af1213 fix: hover on publish button 2026-02-05 10:32:15 -07:00
tdgao c893dfcb22 add todo 2026-02-05 10:15:01 -07:00
Prospector c958091ee6 owner selector copy 2026-02-04 07:37:36 -08:00
Prospector 37b9610d26 prepr 2026-02-04 07:34:34 -08:00
Prospector 7f45734c4d Merge remote-tracking branch 'origin/main' into truman/linked-server-instances
# Conflicts:
#	apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue
#	apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
#	apps/app-frontend/src/pages/instance/Index.vue
#	apps/app-frontend/src/pages/project/Index.vue
2026-02-04 07:29:37 -08:00
tdgao d48f6243a7 small updates 2026-02-03 14:54:08 -07:00
tdgao e5d3eff085 pnpm prepr 2026-02-03 14:39:19 -07:00
tdgao 091a74cf84 add recommended version copy 2026-02-03 14:24:07 -07:00
tdgao 7aeabf60e0 fix uploaded file in custom mrpack content 2026-02-03 14:05:05 -07:00
tdgao 672a18d49f fix type error 2026-02-03 13:50:33 -07:00
tdgao 2f131d4ac7 Merge branch 'main' into truman/linked-server-instances 2026-02-03 13:41:29 -07:00
tdgao 892f8e5946 fix date 2026-02-03 13:32:10 -07:00
tdgao 3877d40b64 update create version modal to hook up to backend 2026-02-03 13:20:13 -07:00
tdgao 87aecc267d use requested_status and organization_id 2026-02-03 12:55:35 -07:00
tdgao 65d0803c37 update table 2026-02-02 20:23:17 -07:00
tdgao 4ce81f0e8f implement transfer to org on create project 2026-02-02 19:44:59 -07:00
tdgao d935a8f983 hook up create server project endpoint 2026-02-02 19:32:45 -07:00
tdgao 04b3488dbc remove server project feature flag and mock get project v3 route 2026-02-02 18:38:15 -07:00
tdgao acccddffc3 delete duplicate keys 2026-01-30 12:55:31 -07:00
tdgao 7e6470e361 pnpm prepr 2026-01-30 10:47:49 -07:00
tdgao 43ab52a902 Merge branch 'main' into truman/linked-server-instances 2026-01-30 10:45:56 -07:00
tdgao 68a09b1c9c implement locking required version and hiding supported versions when using mrpack 2026-01-29 23:01:48 -07:00
tdgao 3eea154520 fix: combobox disabled state 2026-01-29 22:48:32 -07:00
tdgao dd7b8fdb46 add todos 2026-01-29 22:40:38 -07:00
tdgao bba62c6b98 feat: implement required and supported versions 2026-01-29 22:38:29 -07:00
tdgao 6fb15eb4c5 feat: implement update to play modal 2026-01-29 15:34:17 -07:00
tdgao 93e292d5a6 start update to play moadl 2026-01-29 11:00:50 -07:00
tdgao 6c2ab769c4 small copy updates to install to play modal 2026-01-29 11:00:41 -07:00
tdgao 2092ba85ed fix hide modal on install to play 2026-01-29 09:57:06 -07:00
tdgao 1032469239 feat: implement fetch modcount for project inside install to play 2026-01-28 12:42:27 -07:00
tdgao a0858efbcd Merge branch 'worlds-and-shared-instances-components' into truman/linked-server-instances 2026-01-27 21:03:55 -07:00
tdgao a8c4e2dfb1 feat: update table component to derive key from column instead of data 2026-01-27 21:02:33 -07:00
tdgao 1d7c8a0bdd update above table buttons 2026-01-27 20:37:29 -07:00
tdgao e67f25166c update active tag 2026-01-27 20:24:23 -07:00
tdgao 02a9f4c722 fix: allow more loose typing on columns 2026-01-26 16:10:04 -07:00
tdgao 11769df7b7 add mocked server proj versions table 2026-01-26 16:09:03 -07:00
tdgao 021b85928d Merge branch 'worlds-and-shared-instances-components' into truman/linked-server-instances 2026-01-26 15:10:40 -07:00
tdgao 9783cbf52b chore: export table in index 2026-01-26 15:10:31 -07:00
tdgao 51398a1323 Merge branch 'worlds-and-shared-instances-components' into truman/linked-server-instances 2026-01-26 14:55:54 -07:00
tdgao 8392ae3513 feat: add surface-1.5 and use in table 2026-01-26 14:48:06 -07:00
tdgao ccee5e0ca7 Merge branch 'worlds-and-shared-instances-components' into truman/linked-server-instances 2026-01-26 14:13:40 -07:00
tdgao b1972afb16 feat: add table overflow menu story example 2026-01-26 14:11:57 -07:00
tdgao 292b3f8e8c feat: add column width prop for table and fix stories 2026-01-26 13:59:45 -07:00
tdgao e63787c5dd feat: reusable table component 2026-01-26 13:49:23 -07:00
tdgao 5232929c5a fixes from merge with main 2026-01-26 12:32:03 -07:00
tdgao bfaed9a9a5 update get project route 2026-01-26 12:16:49 -07:00
tdgao f281a804ea add api routes for server projects 2026-01-26 12:14:42 -07:00
tdgao 50f738101e pnpm prepr 2026-01-26 10:50:22 -07:00
tdgao 71074d8499 pnpm prepr 2026-01-26 10:47:15 -07:00
tdgao 138b5e9971 Merge branch 'worlds-and-shared-instances-components' into truman/linked-server-instances 2026-01-26 10:44:29 -07:00
tdgao e165180a8d remove install to play modal from ui package 2026-01-26 10:37:07 -07:00
Calum H.andGitHub 61e69ac0d7 Merge branch 'main' into worlds-and-shared-instances-components
Signed-off-by: Calum H. <contact@cal.engineer>
2026-01-26 08:33:10 +00:00
Calum H. (IMB11) 0c12fbed2f feat: i18n pass 2026-01-26 08:30:14 +00:00
Calum H. (IMB11) 1e70473574 feat: finish content updater modal 2026-01-26 08:21:10 +00:00
Calum H. (IMB11) 16f46a464e feat: start on updater modal 2026-01-24 22:08:24 +00:00
Calum H. (IMB11) 1712ed2fd2 fix: storybook 2026-01-24 18:21:59 +00:00
Calum H. (IMB11) af35e92218 fix: fallback to svg if src is invalid on avatar component 2026-01-22 13:37:48 +00:00
Calum H. (IMB11) 4877eaee5d fix: hasUpdate type 2026-01-22 13:16:51 +00:00
Calum H. (IMB11) 067b3466ee fix: use TeleportOverflowMenu 2026-01-22 13:13:00 +00:00
Calum H. (IMB11) d4af422214 feat: fix gap + border issues on last elm 2026-01-22 12:17:36 +00:00
Calum H. (IMB11) bd5fbb3d09 feat: virtualization + smaller contentcard items 2026-01-22 12:07:13 +00:00
Calum H. (IMB11) 6f84acb01b fix: lint 2026-01-22 11:38:10 +00:00
Calum H. (IMB11) ff5ed4045d feat: virtual list impl for card table based on window scroll 2026-01-22 11:35:31 +00:00
Calum H. (IMB11) 144bde8976 feat: disabled state 2026-01-22 11:18:28 +00:00
Calum H. (IMB11) 1850de7b8d fix: row borders 2026-01-22 11:01:19 +00:00
Calum H. (IMB11) e0c871c0e4 feat: migrate toggle to tailwind 2026-01-22 10:54:31 +00:00
Calum H. (IMB11) f46a4fb4c6 feat: figma alignments 2026-01-22 10:29:59 +00:00
tdgao db3f745562 implement install function and move install to play modal into app-frontend 2026-01-20 17:38:36 -07:00
tdgao 35b962178e Merge branch 'worlds-and-shared-instances-components' into truman/linked-server-instances 2026-01-20 14:29:49 -07:00
tdgao 2e900879da Merge branch 'worlds-and-shared-instances-components' into truman/linked-server-instances 2026-01-20 14:29:22 -07:00
tdgao 7135ef4cf4 implement open in app modal 2026-01-20 14:13:26 -07:00
Calum H. (IMB11) 95ea6df681 fix: events 2026-01-20 17:29:50 +00:00
tdgao 51d84ca80c feat: start install to play modal 2026-01-19 14:00:58 -07:00
tdgao 1591fcc795 fix: update file dropzone size 2026-01-19 12:22:12 -07:00
tdgao e4536b5f66 Merge branch 'worlds-and-shared-instances-components' into truman/linked-server-instances 2026-01-19 12:01:01 -07:00
tdgao b5a5a6a10c clean up project combobox 2026-01-19 12:00:05 -07:00
tdgao 1501b11f7b feat: add project combobox 2026-01-19 11:46:09 -07:00
tdgao 63a90f7051 use new showIconInSelected prop 2026-01-19 11:26:46 -07:00
tdgao ea2431b2dd Merge branch 'worlds-and-shared-instances-components' into truman/linked-server-instances 2026-01-19 11:08:13 -07:00
tdgao 973208730e add show icon in selected for combobox with stories 2026-01-19 11:06:58 -07:00
tdgao 2dc3dad296 fix: update header 2026-01-19 10:59:12 -07:00
Calum H. (IMB11) 4a26d17951 feat: selection v-model 2026-01-19 15:39:04 +00:00
Calum H. (IMB11) 74cc23054d fix: extract types 2026-01-19 09:42:34 +00:00
Calum H. (IMB11) b54276fb10 feat: add ContentModpackCard 2026-01-19 09:32:31 +00:00
tdgao 765e3fba0e update links page 2026-01-16 11:40:54 -07:00
tdgao 6e84c922fa feat: implement create server project version and server general settings page 2026-01-16 11:26:35 -07:00
Calum H.andGitHub bd5f0364c2 Merge branch 'main' into worlds-and-shared-instances-components 2026-01-16 10:11:32 +00:00
Calum H. (IMB11) 847d752dae feat: fix orgs 2026-01-16 10:06:56 +00:00
Calum H. (IMB11) 548c1f3be4 fix: tooltips + colors 2026-01-16 09:57:56 +00:00
Calum H. (IMB11) 6328d4d92a feat: base content card component 2026-01-16 09:47:13 +00:00
tdgao 93805bc7b9 feat: update nav links for servers 2026-01-15 23:37:45 -07:00
tdgao 6753e95922 fix spacing 2026-01-15 21:06:18 -07:00
tdgao 2dcc031a52 add server project related fields 2026-01-15 21:01:47 -07:00
tdgao 8536ca52ea feat: create project modal to use typescript 2026-01-15 20:10:29 -07:00
tdgao b5c69958ee fix: url text input wrapper before 2026-01-15 20:07:01 -07:00
tdgao 6e4abf6e26 feat: add create server project in dropdown 2026-01-15 16:35:46 -07:00
312 changed files with 19267 additions and 4449 deletions
+11 -1
View File
@@ -21,6 +21,14 @@ on:
type: boolean
default: false
required: false
environment:
description: Environment
type: choice
options:
- prod
- staging
default: prod
required: false
jobs:
build:
@@ -94,12 +102,14 @@ jobs:
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
BUILD_ENVIRONMENT="${{ inputs.environment || 'prod' }}"
echo "Setting application version to $APP_VERSION"
echo "Using environment $BUILD_ENVIRONMENT"
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp packages/app-lib/.env.prod packages/app-lib/.env
cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
Generated
+58
View File
@@ -640,6 +640,19 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-minecraft-ping"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b459c14dd8d9ef21e296af3f2a3651ff7dc3536e092fb0b09e528daaa6d89"
dependencies = [
"async-trait",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -2167,6 +2180,16 @@ dependencies = [
"darling_macro 0.21.3",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
version = "0.20.11"
@@ -2195,6 +2218,19 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
@@ -2217,6 +2253,17 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"quote",
"syn 2.0.106",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@@ -4730,6 +4777,7 @@ dependencies = [
"arc-swap",
"argon2",
"ariadne",
"async-minecraft-ping",
"async-stripe",
"async-trait",
"base64 0.22.1",
@@ -4811,6 +4859,16 @@ dependencies = [
"zxcvbn",
]
[[package]]
name = "labrinth-derive"
version = "0.0.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "language-tags"
version = "0.3.2"
+6
View File
@@ -8,6 +8,7 @@ members = [
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
@@ -32,6 +33,7 @@ arc-swap = "1.7.1"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.32", default-features = false }
async-minecraft-ping = { version = "0.8.0" }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
@@ -58,6 +60,7 @@ color-eyre = "0.6.5"
color-thief = "0.2.2"
const_format = "0.2.34"
daedalus = { path = "packages/daedalus" }
darling = { version = "0.23" }
dashmap = "6.1.0"
data-url = "0.3.2"
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
@@ -121,9 +124,11 @@ paste = "1.0.15"
path-util = { path = "packages/path-util" }
phf = { version = "0.13.1", features = ["macros"] }
png = "0.18.0"
proc-macro2 = { version = "1.0" }
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.38.3"
quote = { version = "1.0" }
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "0.32.7"
@@ -166,6 +171,7 @@ spdx = "0.12.0"
sqlx = { version = "0.8.6", default-features = false }
sqlx-tracing = { path = "packages/sqlx-tracing" }
strum = "0.27.2"
syn = { version = "2.0" }
sysinfo = { version = "0.37.2", default-features = false }
tar = "0.4.44"
tauri = "2.8.5"
+152 -68
View File
@@ -36,14 +36,16 @@ import {
NewsArticleCard,
NotificationPanel,
OverflowMenu,
PopupNotificationPanel,
ProgressSpinner,
provideModrinthClient,
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { formatBytes, renderString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
@@ -61,6 +63,7 @@ import AccountsCard from '@/components/ui/AccountsCard.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
@@ -68,13 +71,13 @@ import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import UpdateAvailableToast from '@/components/ui/UpdateAvailableToast.vue'
import UpdateToast from '@/components/ui/UpdateToast.vue'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
@@ -101,13 +104,14 @@ import {
subscribeToDownloadProgress,
} from '@/providers/download-progress.ts'
import { useError } from '@/store/error.js'
import { useInstall } from '@/store/install.js'
import { playServerProject, useInstall } from '@/store/install.js'
import { useLoading, useTheming } from '@/store/state'
import { create_profile_and_install_from_file } from './helpers/pack'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
const themeStore = useTheming()
@@ -115,6 +119,10 @@ const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
const { handleError, addNotification } = notificationManager
const popupNotificationManager = new AppPopupNotificationManager()
providePopupNotificationManager(popupNotificationManager)
const { addPopupNotification } = popupNotificationManager
const tauriApiClient = new TauriModrinthClient({
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
features: [
@@ -393,8 +401,11 @@ const minecraftAuthErrorModal = ref()
const install = useInstall()
const modInstallModal = ref()
const addServerToInstanceModal = ref()
const installConfirmModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const updateToPlayModal = ref()
const credentials = ref()
@@ -473,6 +484,10 @@ onMounted(() => {
install.setIncompatibilityWarningModal(incompatibilityWarningModal)
install.setInstallConfirmModal(installConfirmModal)
install.setModInstallModal(modInstallModal)
install.setAddServerToInstanceModal(addServerToInstanceModal)
install.setInstallToPlayModal(installToPlayModal)
install.setUpdateToPlayModal(updateToPlayModal)
install.setPopupNotificationManager(popupNotificationManager)
})
const accounts = ref(null)
@@ -490,6 +505,9 @@ async function handleCommand(e) {
source: 'CreationModalFileDrop',
})
}
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
} else {
// Other commands are URL-based (deep linking)
urlModal.value.show(e)
@@ -508,14 +526,60 @@ const downloadPercent = computed(() => Math.trunc(appUpdateDownload.progress.val
const metered = ref(true)
const finishedDownloading = ref(false)
const restarting = ref(false)
const updateToastDismissed = ref(false)
const availableUpdate = ref(null)
const updateSize = ref(null)
const updatesEnabled = ref(true)
const updatePopupMessages = defineMessages({
updateAvailable: {
id: 'app.update-popup.title',
defaultMessage: 'Update available',
},
downloadComplete: {
id: 'app.update-popup.download-complete',
defaultMessage: 'Download complete',
},
body: {
id: 'app.update-popup.body',
defaultMessage:
'Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App.',
},
meteredBody: {
id: 'app.update-popup.body.metered',
defaultMessage: `Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it.`,
},
downloadedBody: {
id: 'app.update-popup.body.download-complete',
defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`,
},
linuxBody: {
id: 'app.update-popup.body.linux',
defaultMessage:
'Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!',
},
reload: {
id: 'app.update-popup.reload',
defaultMessage: 'Reload',
},
download: {
id: 'app.update-popup.download',
defaultMessage: 'Download ({size})',
},
changelog: {
id: 'app.update-popup.changelog',
defaultMessage: 'Changelog',
},
})
async function checkUpdates() {
if (!(await areUpdatesEnabled())) {
console.log('Skipping update check as updates are disabled in this build or environment')
updatesEnabled.value = false
if (os.value === 'Linux' && !isDevEnvironment.value) {
checkLinuxUpdates()
setInterval(checkLinuxUpdates, 5 * 60 * 1000)
}
return
}
@@ -535,7 +599,6 @@ async function checkUpdates() {
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
updateToastDismissed.value = false
console.log(`Update ${update.version} is available.`)
@@ -545,6 +608,28 @@ async function checkUpdates() {
downloadUpdate(update)
} else {
console.log(`Metered connection detected, not auto-downloading update.`)
getUpdateSize(update.rid).then((size) => {
updateSize.value = size
addPopupNotification({
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
type: 'info',
autoCloseMs: null,
buttons: [
{
label: formatMessage(updatePopupMessages.download, {
size: formatBytes(updateSize.value ?? 0),
}),
action: () => downloadAvailableUpdate(),
color: 'brand',
},
{
label: formatMessage(updatePopupMessages.changelog),
action: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
},
],
})
})
}
getUpdateSize(update.rid).then((size) => (updateSize.value = size))
@@ -561,8 +646,26 @@ async function checkUpdates() {
)
}
async function showUpdateToast() {
updateToastDismissed.value = false
async function checkLinuxUpdates() {
try {
const [response, currentVersion] = await Promise.all([
fetch('https://launcher-files.modrinth.com/updates.json'),
getVersion(),
])
const updates = await response.json()
const latestVersion = updates?.version
if (latestVersion && latestVersion !== currentVersion) {
addPopupNotification({
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
type: 'info',
autoCloseMs: null,
})
}
} catch (e) {
console.error('Failed to check for updates:', e)
}
}
async function downloadAvailableUpdate() {
@@ -588,6 +691,26 @@ async function downloadUpdate(versionToDownload) {
unlistenUpdateDownload = null
})
console.log('Finished downloading!')
addPopupNotification({
title: formatMessage(updatePopupMessages.downloadComplete),
text: formatMessage(updatePopupMessages.downloadedBody, {
version: versionToDownload.version,
}),
type: 'success',
autoCloseMs: null,
buttons: [
{
label: formatMessage(updatePopupMessages.reload),
action: () => installUpdate(),
color: 'brand',
},
{
label: formatMessage(updatePopupMessages.changelog),
action: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
},
],
})
})
unlistenUpdateDownload = await subscribeToDownloadProgress(
appUpdateDownload,
@@ -761,25 +884,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="app-grid-layout experimental-styles-within relative"
:class="{ 'disable-advanced-rendering': !themeStore.advancedRendering }"
>
<Suspense>
<Transition name="toast">
<UpdateToast
v-if="
!!availableUpdate &&
!updateToastDismissed &&
!restarting &&
(finishedDownloading || metered)
"
:version="availableUpdate.version"
:size="updateSize"
:metered="metered"
@close="updateToastDismissed = true"
@restart="installUpdate"
@download="downloadAvailableUpdate"
/>
<UpdateAvailableToast v-else-if="!updatesEnabled && os === 'Linux' && !isDevEnvironment" />
</Transition>
</Suspense>
<Transition name="fade">
<div
v-if="restarting"
@@ -856,14 +960,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</NavButton>
<div class="flex flex-grow"></div>
<Transition name="nav-button-animated">
<div
v-if="
availableUpdate &&
updateToastDismissed &&
!restarting &&
(finishedDownloading || metered)
"
>
<div v-if="availableUpdate && !restarting && (finishedDownloading || metered)">
<NavButton
v-tooltip.right="
formatMessage(
@@ -877,13 +974,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
},
)
"
:to="
finishedDownloading
? installUpdate
: downloadProgress > 0 && downloadProgress < 1
? showUpdateToast
: downloadAvailableUpdate
"
:to="finishedDownloading ? installUpdate : downloadAvailableUpdate"
>
<ProgressSpinner
v-if="downloadProgress > 0 && downloadProgress < 1"
@@ -918,13 +1009,25 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
]"
placement="right-end"
>
<Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
<Avatar
v-if="credentials.user?.avatar_url"
:src="credentials.user.avatar_url"
alt=""
size="32px"
circle
/>
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
Signed in as
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials.user.avatar_url" alt="" size="20px" circle />
<Avatar
v-if="credentials.user?.avatar_url"
:src="credentials.user.avatar_url"
alt=""
size="20px"
circle
/>
{{ credentials.user.username }}
</span>
</span>
@@ -1133,11 +1236,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<URLConfirmModal ref="urlModal" />
<I18nDebugPanel />
<NotificationPanel has-sidebar />
<PopupNotificationPanel has-sidebar />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<ModInstallModal ref="modInstallModal" />
<AddServerToInstanceModal ref="addServerToInstanceModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
<InstallConfirmModal ref="installConfirmModal" />
<InstallToPlayModal ref="installToPlayModal" />
<UpdateToPlayModal ref="updateToPlayModal" />
</template>
<style lang="scss" scoped>
@@ -1366,38 +1473,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
.toast-enter-active {
transition: opacity 0.25s linear;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: no-preference) {
.toast-enter-active,
.nav-button-animated-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
}
.toast-leave-active,
.nav-button-animated-leave-active {
transition: all 0.25s ease;
}
.toast-enter-from {
scale: 0.5;
translate: 0 -10rem;
opacity: 0;
}
.toast-leave-to {
scale: 0.96;
translate: 20rem 0;
opacity: 0;
}
.nav-button-animated-enter-active {
position: relative;
}
@@ -1461,7 +1545,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}
.info-card {
right: 8rem;
right: 22rem;
}
.profile-card {
+63 -39
View File
@@ -1,5 +1,7 @@
<template>
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<RouterLink
@@ -14,13 +16,17 @@
<span class="text-nowrap">{{ link.label }}</span>
</RouterLink>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`"
:class="[
'pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1',
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
{ 'navtabs-transition': transitionsEnabled },
]"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
opacity: sliderReady && activeIndex !== -1 ? 1 : 0,
}"
aria-hidden="true"
></div>
@@ -28,7 +34,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
@@ -47,13 +53,16 @@ const props = defineProps<{
query?: string
}>()
const scrollContainer = ref<HTMLElement | null>(null)
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
const subpageSelected = ref(false)
const sliderReady = ref(false)
const transitionsEnabled = ref(false)
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
@@ -63,13 +72,18 @@ const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const leftDelay = computed(() => sliderDelays.value.left)
const rightDelay = computed(() => sliderDelays.value.right)
const topDelay = computed(() => sliderDelays.value.top)
const bottomDelay = computed(() => sliderDelays.value.bottom)
function pickLink() {
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
if (route.path === (typeof link.href === 'string' ? link.href : (link.href as any).path)) {
index = i
break
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
@@ -83,57 +97,54 @@ function pickLink() {
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderLeft.value = 0
sliderRight.value = 0
}
}
const tabLinkElements = ref()
function getTabElement(index: number): HTMLElement | null {
if (index === -1) return null
const container = scrollContainer.value
if (!container) return null
const tabs = container.querySelectorAll('.button-animation')
return (tabs[index] as HTMLElement) ?? null
}
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return
const el = getTabElement(activeIndex.value)
if (!el?.offsetParent) return
const parent = el.offsetParent as HTMLElement
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
sliderReady.value = true
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
} else {
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
const STAGGER_DELAY = '200ms'
sliderDelays.value = {
left: newValues.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
right: newValues.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
top: newValues.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
bottom: newValues.top < sliderTop.value ? STAGGER_DELAY : '0ms',
}
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
}
}
@@ -146,7 +157,17 @@ onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(route, () => {
watch(
filteredLinks,
async () => {
await nextTick()
pickLink()
},
{ deep: true },
)
watch(route, async () => {
await nextTick()
pickLink()
})
</script>
@@ -154,7 +175,10 @@ watch(route, () => {
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>
@@ -69,7 +69,10 @@ onUnmounted(() => {
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
<div
v-if="instances && recentInstances.length > 0"
class="h-px w-6 mx-auto my-2 bg-divider"
></div>
</template>
<style scoped lang="scss"></style>
@@ -52,10 +52,12 @@
<h3 class="info-title">
{{ loadingBar.title }}
</h3>
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
{{ loadingBar.message }}
<div class="flex flex-col gap-2 w-full">
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
{{ loadingBar.message }}
</div>
</div>
</div>
</Card>
@@ -102,7 +104,12 @@ import {
TerminalSquareIcon,
UnplugIcon,
} from '@modrinth/assets'
import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
import {
Button,
ButtonStyled,
Card,
injectNotificationManager,
} from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -176,7 +183,7 @@ const goToTerminal = (path) => {
const currentLoadingBars = ref([])
const refreshInfo = async () => {
const currentLoadingBarCount = currentLoadingBars.value.length
const previousBars = [...currentLoadingBars.value]
currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError))
.map((x) => {
if (x.bar_type.type === 'java_download') {
@@ -205,7 +212,7 @@ const refreshInfo = async () => {
if (currentLoadingBars.value.length === 0) {
showCard.value = false
} else if (currentLoadingBarCount < currentLoadingBars.value.length) {
} else if (previousBars.length < currentLoadingBars.value.length) {
showCard.value = true
}
}
@@ -346,7 +353,7 @@ onBeforeUnmount(() => {
.info-card {
position: absolute;
top: 3.5rem;
right: 0.5rem;
right: 100%;
z-index: 9;
width: 20rem;
background-color: var(--color-raised-bg);
@@ -420,7 +427,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
gap: 0.75rem;
margin: 0;
padding: 0;
}
@@ -1,84 +0,0 @@
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { onMounted, onUnmounted, ref } from 'vue'
const { formatMessage } = useVIntl()
const dismissed = ref(false)
const availableUpdate = ref<{ version: string } | null>(null)
let checkInterval: ReturnType<typeof setInterval> | null = null
async function checkForUpdate() {
try {
const [response, currentVersion] = await Promise.all([
fetch('https://launcher-files.modrinth.com/updates.json'),
getVersion(),
])
const updates = await response.json()
const latestVersion = updates?.version
if (latestVersion && latestVersion !== currentVersion) {
if (latestVersion !== availableUpdate.value?.version) {
availableUpdate.value = { version: latestVersion }
dismissed.value = false
}
}
} catch (e) {
console.error('Failed to check for updates:', e)
}
}
function dismiss() {
dismissed.value = true
}
onMounted(() => {
checkForUpdate()
checkInterval = setInterval(checkForUpdate, 5 * 60 * 1000)
})
onUnmounted(() => {
if (checkInterval) {
clearInterval(checkInterval)
}
})
const messages = defineMessages({
title: {
id: 'app.update-toast.title',
defaultMessage: 'Update available',
},
body: {
id: 'app.update-toast.body.linux',
defaultMessage:
'Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!',
},
download: {
id: 'app.update-toast.download-page',
defaultMessage: 'Download',
},
})
</script>
<template>
<div
v-if="availableUpdate && !dismissed"
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
>
<div class="flex min-w-[25rem] gap-4">
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
{{ formatMessage(messages.title) }}
</h2>
<ButtonStyled size="small" circular>
<button v-tooltip="formatMessage(commonMessages.closeButton)" @click="dismiss">
<XIcon />
</button>
</ButtonStyled>
</div>
<p class="text-sm mt-2 mb-0">
{{ formatMessage(messages.body, { version: availableUpdate.version }) }}
</p>
</div>
</template>
@@ -1,130 +0,0 @@
<script setup lang="ts">
import { DownloadIcon, ExternalIcon, RefreshCwIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { formatBytes } from '@modrinth/utils'
import { ref } from 'vue'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'close' | 'restart' | 'download'): void
}>()
defineProps<{
version: string
size: number | null
metered: boolean
}>()
const downloading = ref(false)
const { progress } = injectAppUpdateDownloadProgress()
function download() {
emit('download')
downloading.value = true
}
const messages = defineMessages({
title: {
id: 'app.update-toast.title',
defaultMessage: 'Update available',
},
body: {
id: 'app.update-toast.body',
defaultMessage:
'Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App.',
},
reload: {
id: 'app.update-toast.reload',
defaultMessage: 'Reload',
},
download: {
id: 'app.update-toast.download',
defaultMessage: 'Download ({size})',
},
downloading: {
id: 'app.update-toast.downloading',
defaultMessage: 'Downloading...',
},
changelog: {
id: 'app.update-toast.changelog',
defaultMessage: 'Changelog',
},
meteredBody: {
id: 'app.update-toast.body.metered',
defaultMessage: `Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it.`,
},
downloadCompleteTitle: {
id: 'app.update-toast.title.download-complete',
defaultMessage: 'Download complete',
},
downloadedBody: {
id: 'app.update-toast.body.download-complete',
defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`,
},
})
</script>
<template>
<div
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
:class="{
'download-complete': progress === 1,
}"
>
<div class="flex min-w-[25rem] gap-4">
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
{{
formatMessage(metered && progress === 1 ? messages.downloadCompleteTitle : messages.title)
}}
</h2>
<ButtonStyled size="small" circular>
<button v-tooltip="formatMessage(commonMessages.closeButton)" @click="emit('close')">
<XIcon />
</button>
</ButtonStyled>
</div>
<p class="text-sm mt-2 mb-0">
{{
formatMessage(
metered
? progress === 1
? messages.downloadedBody
: messages.meteredBody
: messages.body,
{ version },
)
}}
</p>
<p
v-if="metered && progress < 1"
class="text-sm text-secondary mt-2 mb-0 flex items-center gap-1"
>
<template v-if="progress > 0">
<ProgressBar :progress="progress" class="max-w-[unset]" />
</template>
</p>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button v-if="metered && progress < 1" :disabled="downloading" @click="download">
<SpinnerIcon v-if="downloading" class="animate-spin" />
<DownloadIcon v-else />
{{
formatMessage(downloading ? messages.downloading : messages.download, {
size: formatBytes(size ?? 0),
})
}}
</button>
<button v-else @click="emit('restart')">
<RefreshCwIcon /> {{ formatMessage(messages.reload) }}
</button>
</ButtonStyled>
<ButtonStyled>
<a href="https://modrinth.com/news/changelog?filter=app">
{{ formatMessage(messages.changelog) }} <ExternalIcon />
</a>
</ButtonStyled>
</div>
</div>
</template>
@@ -0,0 +1,136 @@
<script setup>
import {
CheckIcon,
PlusIcon,
SearchIcon,
} from '@modrinth/assets'
import { Admonition, Avatar, ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/profile'
import { add_server_to_profile, get_profile_worlds } from '@/helpers/worlds.ts'
const { handleError } = injectNotificationManager()
const modal = ref()
const searchFilter = ref('')
const profiles = ref([])
const serverName = ref('')
const serverAddress = ref('')
const shownProfiles = computed(() =>
profiles.value.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
)
defineExpose({
show: async (name, address) => {
serverName.value = name
serverAddress.value = address
searchFilter.value = ''
const profilesVal = await list().catch(handleError)
for (const profile of profilesVal) {
profile.adding = false
profile.added = false
try {
const worlds = await get_profile_worlds(profile.path)
profile.added = worlds.some(
(w) => w.type === 'server' && w.address === serverAddress.value,
)
} catch {
// Ignore - will show as not added
}
}
profiles.value = profilesVal
modal.value.show()
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
},
})
async function addServer(profile) {
profile.adding = true
try {
await add_server_to_profile(
profile.path,
serverName.value,
serverAddress.value,
'prompt',
)
profile.added = true
trackEvent('AddServerToInstance', {
server_name: serverName.value,
instance_name: profile.name,
source: 'AddServerToInstanceModal',
})
} catch (err) {
handleError(err)
}
profile.adding = false
}
</script>
<template>
<ModalWrapper ref="modal" header="Add server to instance">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition type="warning" body="This server may not be compatible with all instances." />
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="search"
placeholder="Search for an instance"
autocomplete="off"
/>
<div class="max-h-[21rem] overflow-y-auto">
<div
v-for="profile in shownProfiles"
:key="profile.path"
class="flex w-full items-center justify-between gap-2 bg-bg-raised text-icon shadow-none"
>
<router-link
class="btn btn-transparent p-2 text-left"
:to="`/instance/${encodeURIComponent(profile.path)}`"
@click="modal.hide()"
>
<Avatar
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
class="mr-2 [--size:2rem]"
/>
{{ profile.name }}
</router-link>
<ButtonStyled>
<button
:disabled="profile.added || profile.adding"
@click="addServer(profile)"
>
<PlusIcon v-if="!profile.added && !profile.adding" />
<CheckIcon v-else-if="profile.added" />
{{
profile.adding
? 'Adding...'
: profile.added
? 'Added'
: 'Add'
}}
</button>
</ButtonStyled>
</div>
</div>
<div class="input-group push-right">
<ButtonStyled>
<button @click="modal.hide()">Cancel</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
@@ -16,6 +16,7 @@ import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3_many } from '@/helpers/cache.js'
import {
add_project_from_version as installMod,
check_installed,
@@ -81,6 +82,22 @@ defineExpose({
handleError,
)
}
const linkedProjectIds = profilesVal
.filter((p) => p.linked_data?.project_id)
.map((p) => p.linked_data.project_id)
if (linkedProjectIds.length > 0) {
const linkedProjects = await get_project_v3_many(linkedProjectIds, 'stale_while_revalidate_skip_offline').catch(
() => [],
)
const serverProjectIds = new Set(
linkedProjects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
)
for (const profile of profilesVal) {
profile.isServerInstance = serverProjectIds.has(profile.linked_data?.project_id)
}
}
profiles.value = profilesVal
installModal.value.show()
@@ -29,7 +29,7 @@ import { computed, type ComputedRef, type Ref, ref, shallowRef, watch } from 'vu
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache'
import { get_project, get_version, get_version_many } from '@/helpers/cache'
import { get_loader_versions } from '@/helpers/metadata'
import { edit, install, update_repair_modrinth } from '@/helpers/profile'
import { get_game_versions, get_loaders } from '@/helpers/tags'
@@ -110,6 +110,12 @@ if (props.instance.linked_data && props.instance.linked_data.project_id && !prop
versions.find(
(version: Version) => version.id === props.instance.linked_data?.version_id,
) ?? null
if (!modpackVersion.value) {
get_version(props.instance.linked_data?.version_id, 'bypass')
.then((version: Version) => (modpackVersion.value = version ?? null))
.catch(handleError)
}
})
.catch(handleError)
.finally(() => {
@@ -424,6 +430,18 @@ const messages = defineMessages({
id: 'instance.settings.tabs.installation.unlink.description',
defaultMessage: `This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack.`,
},
unlinkServerTitle: {
id: 'instance.settings.tabs.installation.unlink-server.title',
defaultMessage: 'Unlink from server',
},
unlinkServerDescription: {
id: 'instance.settings.tabs.installation.unlink-server.description',
defaultMessage: `This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server.`,
},
unlinkServerVanillaDescription: {
id: 'instance.settings.tabs.installation.unlink-server-vanilla.description',
defaultMessage: `This instance is linked to a server, which means you can't change the Minecraft version. Unlinking will permanently disconnect this instance from the server.`,
},
unlinkInstanceButton: {
id: 'instance.settings.tabs.installation.unlink.button',
defaultMessage: 'Unlink instance',
@@ -557,17 +575,27 @@ const messages = defineMessages({
})
}}
</span>
<span class="text-sm text-secondary leading-none">
<span class="text-sm text-secondary leading-none capitalize">
{{
modpackProject
? modpackVersion
? modpackVersion?.version_number
: 'Unknown version'
: props.isMinecraftServer
? ''
: 'Unknown version'
: formatLoader(formatMessage, instance.loader)
}}
<template v-if="instance.loader !== 'vanilla' && !modpackProject">
{{ instance.loader_version || formatMessage(messages.unknownVersion) }}
</template>
<template
v-else-if="
instance.loader && instance.loader !== 'vanilla' && props.isMinecraftServer
"
>
{{ instance.loader }}
{{ instance.loader_version }}
</template>
</span>
</div>
</div>
@@ -599,7 +627,10 @@ const messages = defineMessages({
}}
</button>
</ButtonStyled>
<ButtonStyled v-if="modpackProject" hover-color-fill="background">
<ButtonStyled
v-if="modpackProject && !props.isMinecraftServer"
hover-color-fill="background"
>
<button
v-tooltip="
changingVersion
@@ -754,17 +785,29 @@ const messages = defineMessages({
<template v-else>
<template v-if="instance.linked_data && instance.linked_data.locked">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.unlinkInstanceTitle) }}
{{
formatMessage(
props.isMinecraftServer ? messages.unlinkServerTitle : messages.unlinkInstanceTitle,
)
}}
</h2>
<p class="m-0">
{{ formatMessage(messages.unlinkInstanceDescription) }}
{{
formatMessage(
props.isMinecraftServer
? instance.loader === 'vanilla'
? messages.unlinkServerVanillaDescription
: messages.unlinkServerDescription
: messages.unlinkInstanceDescription,
)
}}
</p>
<ButtonStyled>
<button class="mt-2" @click="modalConfirmUnpair.show()">
<UnlinkIcon /> {{ formatMessage(messages.unlinkInstanceButton) }}
</button>
</ButtonStyled>
<template v-if="modpackProject">
<template v-if="modpackProject && !props.isMinecraftServer">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast block mt-4">
{{ formatMessage(messages.reinstallModpackTitle) }}
@@ -1,36 +1,41 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.sharedServerInstance)">
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
<Avatar
v-if="sharedBy?.icon_url"
:src="sharedBy.icon_url"
:alt="sharedBy.name"
size="24px"
/>
<span>
<IntlFormatted :message-id="messages.sharedByToday">
<template #~name>
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
</template>
</IntlFormatted>
</span>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{
formatMessage(messages.requiredModpack)
}}</span>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.sharedInstance) }}
</span>
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
<ButtonStyled type="transparent">
<button @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar
:src="requiredContentProject.icon_url"
:alt="requiredContentProject.title"
size="48px"
/>
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">{{ project.title }}</span>
<span class="font-semibold text-contrast">
<template v-if="usingCustomModpack">
{{ modpackVersion.name }}
</template>
<template v-else>
{{ requiredContentProject.title }}
</template>
</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
</template>
@@ -57,11 +62,17 @@
</div>
</template>
</NewModal>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="project?.title ?? ''"
:modpack-icon-url="project?.icon_url ?? undefined"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
@@ -69,33 +80,38 @@ import {
commonMessages,
defineMessages,
formatLoader,
IntlFormatted,
ModpackContentModal,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { get_organization, get_team, get_version } from '@/helpers/cache.js'
import { install } from '@/store/install.js'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import {
get_organization,
get_project,
get_project_many,
get_team,
get_version,
get_version_many,
} from '@/helpers/cache.js'
import { installServerProject, useInstall } from '@/store/install.js'
const props = defineProps<{
project: Labrinth.Projects.v2.Project
}>()
import type { ContentItem } from '../../../../../../packages/ui/src/components/instances/types'
const modal = ref<InstanceType<typeof NewModal>>()
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<any>(null)
const project = ref<any>(null)
const requiredContentProject = ref<any>(null)
const organization = ref<any>(null)
const teamMembers = ref<any[]>([])
const onInstallComplete = ref<() => void>(() => {})
const { formatMessage } = useVIntl()
const installStore = useInstall()
const { data: organization } = useQuery({
queryKey: computed(() => ['organization', props.project.organization]),
queryFn: () => get_organization(props.project.organization!, 'must_revalidate'),
enabled: computed(() => !!props.project.organization),
})
const { data: teamMembers } = useQuery({
queryKey: computed(() => ['team', props.project.team]),
queryFn: () => get_team(props.project.team, 'must_revalidate'),
enabled: computed(() => !!props.project.team && !props.project.organization),
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
})
const sharedBy = computed(() => {
@@ -105,7 +121,7 @@ const sharedBy = computed(() => {
icon_url: organization.value.icon_url,
}
}
if (teamMembers.value) {
if (teamMembers.value?.length) {
const owner = teamMembers.value.find((member: { is_owner: boolean }) => member.is_owner)
if (owner) {
return {
@@ -118,26 +134,39 @@ const sharedBy = computed(() => {
})
const loaderDisplay = computed(() => {
const loader = props.project.loaders?.[0]
const loader = requiredContentProject.value?.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
// Fetch the most recent version to get mod count from dependencies
const latestVersionId = computed(() => props.project.versions?.[0] ?? null)
const { data: latestVersion } = useQuery({
queryKey: computed(() => ['version', latestVersionId.value]),
queryFn: () => get_version(latestVersionId.value, 'must_revalidate'),
enabled: computed(() => !!latestVersionId.value),
})
const modCount = computed(() => latestVersion.value?.dependencies?.length)
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
async function fetchData(versionId: string) {
// cache is making version null for some reason so bypassing for now
modpackVersion.value = await get_version(versionId, 'bypass')
if (modpackVersion.value?.project_id) {
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
}
if (project.value?.organization) {
organization.value = await get_organization(project.value.organization, 'bypass')
} else if (project.value?.team_id) {
teamMembers.value = await get_team(project.value.team_id, 'bypass')
}
}
async function handleAccept() {
hide()
const serverProjectId = project.value.id
installStore.startInstallingServer(serverProjectId)
try {
await install(props.project.id, null, null, 'ProjectPageInstallToPlayModal')
await installServerProject(serverProjectId)
onInstallComplete.value()
} catch (error) {
console.error('Failed to install project from InstallToPlayModal:', error)
console.error('Failed to install server project from InstallToPlayModal:', error)
} finally {
installStore.stopInstallingServer(serverProjectId)
}
}
@@ -145,12 +174,91 @@ function handleDecline() {
hide()
}
function show(e?: MouseEvent) {
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
async function openViewContents() {
modpackContentModal.value?.showLoading()
try {
// Ensure version data is available — the useQuery may not have resolved yet
const versionId = modpackVersionId.value
const version =
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
const deps = version?.dependencies ?? []
const projectIds = deps
.map((d: { project_id?: string }) => d.project_id)
.filter((id: string | undefined): id is string => !!id)
const versionIds = deps
.map((d: { version_id?: string }) => d.version_id)
.filter((id: string | undefined): id is string => !!id)
const projects: Labrinth.Projects.v2.Project[] =
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
const versions: Labrinth.Versions.v2.Version[] =
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
const projectMap = new Map(projects.map((p: any) => [p.id, p]))
const contentItems: ContentItem[] = deps.map((dep: any): ContentItem => {
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
const depVersion = dep.version_id ? versions.find((v: any) => v.id === dep.version_id) : null
return {
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
project_type: depProject?.project_type ?? 'mod',
has_update: false,
update_version_id: null,
project: {
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
title: depProject?.title ?? dep.file_name ?? 'Unknown',
icon_url: depProject?.icon_url ?? null,
},
...(depVersion
? {
version: {
id: depVersion.id,
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
version_number: depVersion.version_number ?? undefined,
date_published: depVersion.date_published ?? undefined,
},
}
: {}),
}
})
modpackContentModal.value?.show(contentItems)
} catch (err) {
console.error('Failed to load modpack contents:', err)
modpackContentModal.value?.show([])
}
}
async function show(
projectVal: Labrinth.Projects.v3.Project,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
project.value = projectVal
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
requiredContentProject.value = null
organization.value = null
teamMembers.value = []
onInstallComplete.value = callback
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
hide_ads_window()
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
show_ads_window()
}
const messages = defineMessages({
@@ -162,14 +270,18 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.shared-server-instance',
defaultMessage: 'Shared server instance',
},
contentRequired: {
id: 'app.modal.install-to-play.content-required',
defaultMessage: 'Content required',
},
serverRequiresMods: {
id: 'app.modal.install-to-play.server-requires-mods',
defaultMessage:
'This server requires mods to play. Click install to set up the required files from Modrinth.',
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
},
sharedByToday: {
id: 'app.modal.install-to-play.shared-by-today',
defaultMessage: '{name} shared this instance with you today.',
requiredModpack: {
id: 'app.modal.install-to-play.required-modpack',
defaultMessage: 'Required modpack',
},
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
@@ -183,6 +295,10 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.install-button',
defaultMessage: 'Install',
},
viewContents: {
id: 'app.modal.install-to-play.view-contents',
defaultMessage: 'View contents',
},
})
defineExpose({ show, hide })
@@ -16,7 +16,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
@@ -24,6 +24,7 @@ import InstallationSettings from '@/components/ui/instance_settings/Installation
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_project_v3 } from '@/helpers/cache'
import type { InstanceSettingsTabProps } from '../../../helpers/types'
@@ -31,7 +32,26 @@ const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
const isMinecraftServer = ref(false)
watch(
() => props.instance,
(instance) => {
isMinecraftServer.value = false
if (instance.linked_data?.project_id) {
get_project_v3(instance.linked_data.project_id, 'must_revalidate')
.then((project: any) => {
if (project?.minecraft_server != null) {
isMinecraftServer.value = true
}
})
.catch(() => {})
}
},
{ immediate: true },
)
const tabs = computed<TabbedModalTab<InstanceSettingsTabProps>[]>(() => [
{
name: defineMessage({
id: 'instance.settings.tabs.general',
@@ -72,7 +92,7 @@ const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
icon: CodeIcon,
content: HooksSettings,
},
]
])
const modal = ref()
@@ -98,6 +118,10 @@ defineExpose({ show })
</span>
</template>
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
<TabbedModal
:tabs="
tabs.map((tab) => ({ ...tab, props: { ...props, isMinecraftServer: isMinecraftServer } }))
"
/>
</ModalWrapper>
</template>
@@ -1,8 +1,14 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.updateToPlay)" :closable="true" no-padding>
<div class="max-w-[500px]">
<NewModal
ref="modal"
:header="formatMessage(messages.updateToPlay)"
:closable="true"
no-padding
@hide="() => show_ads_window()"
>
<div v-if="instance" class="max-w-[500px]">
<div class="flex flex-col gap-4 p-4">
<Admonition type="warning" :header="formatMessage(messages.updateRequired)">
<Admonition type="info" :header="formatMessage(messages.updateRequired)">
{{ formatMessage(messages.updateRequiredDescription, { name: instance.name }) }}
</Admonition>
@@ -26,18 +32,25 @@
</div>
</div>
</div>
<div v-if="diffs.length" class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto">
<div
v-if="diffs.length"
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
>
<div
v-for="diff in diffs"
v-for="(diff, index) in diffs"
:key="diff.project_id"
class="grid grid-cols-[auto_1fr_1fr_1fr] items-center min-h-10 h-10 gap-2"
class="grid items-center min-h-10 h-10 gap-2"
:class="diff.project?.title ? 'grid-cols-[auto_1fr_1fr_1fr]' : 'grid-cols-[auto_1fr_1fr]'"
>
<div class="flex flex-col justify-between items-center">
<div class="w-[1px] h-2"></div>
<PlusIcon v-if="diff.type === 'added'" />
<MinusIcon v-else-if="diff.type === 'removed'" />
<RefreshCwIcon v-else />
<div class="bg-surface-5 w-[1px] h-2 relative top-1"></div>
<div
:class="index === diffs.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
class="w-[1px] h-2 relative top-1"
></div>
</div>
<div class="flex gap-1 col-span-2">
@@ -49,14 +62,28 @@
>
{{ diff.project.title }}
</span>
<span
v-else-if="diff.fileName"
v-tooltip="diff.fileName"
class="text-sm text-contrast font-medium truncate"
>
{{ decodeURIComponent(diff.fileName) }}
</span>
</div>
<span
v-if="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
v-tooltip="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
v-if="
diff.project?.title &&
(getFilename(diff.newVersion) || getFilename(diff.currentVersion) || diff.fileName)
"
v-tooltip="getFilename(diff.newVersion) || getFilename(diff.currentVersion) || decodeURIComponent(diff.fileName || '')""
class="text-xs truncate text-right"
>
{{ getFilename(diff.newVersion) || getFilename(diff.currentVersion) }}
{{
getFilename(diff.newVersion) ||
getFilename(diff.currentVersion) ||
decodeURIComponent(diff.fileName || '')
}}
</span>
</div>
</div>
@@ -111,9 +138,11 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { get_project, get_project_many, get_version_many } from '@/helpers/cache.js'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { update_managed_modrinth_version } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { useInstall } from '@/store/install.js'
type Dependency = Labrinth.Versions.v3.Dependency
type Version = Labrinth.Versions.v2.Version
@@ -129,6 +158,7 @@ interface BaseDiff {
newVersionId?: string
currentVersion?: Version
newVersion?: Version
fileName?: string
}
interface AddedDiff extends BaseDiff {
type: 'added'
@@ -151,22 +181,21 @@ type ProjectInfo = {
slug: string
}
const { instance } = defineProps<{
instance: GameInstance
}>()
const { formatMessage } = useVIntl()
const installStore = useInstall()
const modal = ref<InstanceType<typeof NewModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<() => void>(() => {})
const diffs = ref<DependencyDiff[]>([])
const latestVersionId = ref<string | null>(null)
const latestVersion = ref<Version | null>(null)
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const removedCount = computed(() => diffs.value.filter((d) => d.type === 'removed').length)
const addedCount = computed(() => diffs.value.filter((d) => d.type === 'added').length)
const updatedCount = computed(() => diffs.value.filter((d) => d.type === 'updated').length)
const publishedDate = computed(() =>
latestVersion.value?.date_published ? new Date(latestVersion.value.date_published) : null,
modpackVersion.value?.date_published ? new Date(modpackVersion.value.date_published) : null,
)
function getFilename(version?: Version): string | undefined {
@@ -177,18 +206,27 @@ async function computeDependencyDiffs(
currentDeps: Dependency[],
latestDeps: Dependency[],
): Promise<DependencyDiff[]> {
console.log('Computing dependency diffs', { currentDeps, latestDeps })
// Separate deps with project_id from file_name-only deps
const currentWithProject = currentDeps.filter((d) => d.project_id)
const latestWithProject = latestDeps.filter((d) => d.project_id)
const currentFileOnly = currentDeps.filter((d) => !d.project_id && d.file_name)
const latestFileOnly = latestDeps.filter((d) => !d.project_id && d.file_name)
const currentByProject = new Map<string, Dependency>(
currentDeps.map((d) => [d.project_id || '', d]),
currentWithProject.map((d) => [d.project_id!, d]),
)
const latestByProject = new Map<string, Dependency>(
latestDeps.map((d) => [d.project_id || '', d]),
latestWithProject.map((d) => [d.project_id!, d]),
)
const currentFilenames = new Set(currentFileOnly.map((d) => d.file_name!))
const latestFilenames = new Set(latestFileOnly.map((d) => d.file_name!))
const diffs: DependencyDiff[] = []
// Find added and updated dependencies
// Find added and updated dependencies (by project_id)
latestByProject.forEach((latestDep, projectId) => {
if (!projectId) return
const currentDep = currentByProject.get(projectId)
if (!currentDep && latestDep.version_id) {
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
@@ -206,9 +244,8 @@ async function computeDependencyDiffs(
}
})
// Find removed dependencies
// Find removed dependencies (by project_id)
currentByProject.forEach((currentDep, projectId) => {
if (!projectId) return
if (!latestByProject.has(projectId)) {
diffs.push({
type: 'removed',
@@ -218,6 +255,19 @@ async function computeDependencyDiffs(
}
})
// Find added/removed file_name-only dependencies
// ideally in future, this should use the hash of the file instead of filename, but since version dependencies don't include file hashes, we'll use filename as a best effort approach
for (const fileName of latestFilenames) {
if (!currentFilenames.has(fileName)) {
diffs.push({ type: 'added', project_id: '', newVersionId: '' as string, fileName })
}
}
for (const fileName of currentFilenames) {
if (!latestFilenames.has(fileName)) {
diffs.push({ type: 'removed', project_id: '', fileName })
}
}
// Fetch projects and versions of diffs
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
const allVersionIds = [
@@ -228,14 +278,14 @@ async function computeDependencyDiffs(
),
] as string[]
const [projects, versions] = await Promise.all([
get_project_many(allProjectIds, 'must_revalidate'),
get_version_many(allVersionIds, 'must_revalidate'),
get_project_many(allProjectIds, 'bypass'),
get_version_many(allVersionIds, 'bypass'),
])
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
return diffs
const mappedDiffs = diffs
.map((diff) => {
const project = projectMap.get(diff.project_id)
return {
@@ -255,35 +305,25 @@ async function computeDependencyDiffs(
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
})
}).filter((d) => d.project || d.fileName) // filter out any diffs that couldn't be matched to a project or file
return mappedDiffs
}
async function checkUpdateAvailable(instance: GameInstance): Promise<DependencyDiff[] | null> {
if (!instance.linked_data) return null
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
if (!inst.linked_data) return null
try {
const project = await get_project(instance.linked_data.project_id, 'must_revalidate')
if (!project || !project.versions || project.versions.length === 0) {
return null
}
const versions = await get_version_many(project.versions, 'must_revalidate')
const sortedVersions = versions.sort(
(a: { date_published: string }, b: { date_published: string }) =>
dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
)
latestVersion.value = sortedVersions[0]
latestVersionId.value = latestVersion.value?.id || null
const currentVersionId = instance.linked_data.version_id
const currentVersion = versions.find((v: { id: string }) => v.id === currentVersionId)
// For server projects, linked_data.project_id is the server project but
// linked_data.version_id references a content modpack version from a different project.
// Detect this by comparing the version's project_id with linked_data.project_id.
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
const instanceModpackVersion = await get_version(inst.linked_data.version_id, 'bypass')
// Compute dependency diffs between current and latest version
if (currentVersion && latestVersion.value) {
if (instanceModpackVersion && modpackVersion.value) {
return await computeDependencyDiffs(
currentVersion.dependencies || [],
latestVersion.value.dependencies || [],
modpackVersion.value.dependencies || [],
instanceModpackVersion.dependencies || [],
)
}
} catch (error) {
@@ -294,9 +334,10 @@ async function checkUpdateAvailable(instance: GameInstance): Promise<DependencyD
}
watch(
() => instance,
async () => {
const result = await checkUpdateAvailable(instance)
() => instance.value,
async (newInstance) => {
if (!newInstance) return
const result = await checkUpdateAvailable(newInstance)
diffs.value = result || []
},
{ immediate: true, deep: true },
@@ -304,18 +345,25 @@ watch(
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.linked_data?.project_id
if (serverProjectId) installStore.startInstallingServer(serverProjectId)
try {
if (latestVersionId.value) {
await update_managed_modrinth_version(instance.path, latestVersionId.value)
if (modpackVersionId.value && instance.value) {
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
onUpdateComplete.value()
}
} catch (error) {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) installStore.stopInstallingServer(serverProjectId)
}
}
function handleReport() {
if (instance.linked_data?.project_id) {
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.linked_data.project_id}`)
if (instance.value?.linked_data?.project_id) {
openUrl(
`https://modrinth.com/report?item=project&itemID=${instance.value.linked_data.project_id}`,
)
}
}
@@ -323,7 +371,16 @@ function handleDecline() {
hide()
}
function show(e?: MouseEvent) {
function show(
instanceVal: GameInstance,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
instance.value = instanceVal
modpackVersionId.value = modpackVersionIdVal
onUpdateComplete.value = callback
hide_ads_window()
modal.value?.show(e)
}
@@ -378,5 +435,13 @@ const diffTypeMessages = defineMessages({
},
})
defineExpose({ show, hide })
const hasUpdate = computed(() => {
if (!instance.value?.linked_data) return false
return (
modpackVersionId.value != null &&
modpackVersionId.value !== instance.value.linked_data.version_id
)
})
defineExpose({ show, hide, hasUpdate })
</script>
@@ -44,7 +44,7 @@ import type {
SingleplayerWorld,
World,
} from '@/helpers/worlds.ts'
import { getWorldIdentifier, set_world_display_status } from '@/helpers/worlds.ts'
import { getWorldIdentifier, isLinkedWorld, set_world_display_status } from '@/helpers/worlds.ts'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
@@ -117,6 +117,7 @@ const serverIncompatible = computed(
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const linked = computed(() => isLinkedWorld(props.world))
const messages = defineMessages({
hardcore: {
@@ -171,6 +172,10 @@ const messages = defineMessages({
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
linkedServer: {
id: 'instance.worlds.linked_server',
defaultMessage: 'Managed by server project',
},
})
</script>
<template>
@@ -396,8 +401,12 @@ const messages = defineMessages({
id: 'edit',
action: () => emit('edit'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
disabled: locked || linked,
tooltip: locked
? formatMessage(messages.worldInUse)
: linked
? formatMessage(messages.linkedServer)
: undefined,
},
{
id: 'open-folder',
@@ -432,8 +441,12 @@ const messages = defineMessages({
hoverFilled: true,
action: () => emit('delete'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
disabled: locked || linked,
tooltip: locked
? formatMessage(messages.worldInUse)
: linked
? formatMessage(messages.linkedServer)
: undefined,
},
]"
>
+16
View File
@@ -8,6 +8,14 @@ export async function get_project_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_project_many', { ids, cacheBehaviour })
}
export async function get_project_v3(id, cacheBehaviour) {
return await invoke('plugin:cache|get_project_v3', { id, cacheBehaviour })
}
export async function get_project_v3_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_project_v3_many', { ids, cacheBehaviour })
}
export async function get_version(id, cacheBehaviour) {
return await invoke('plugin:cache|get_version', { id, cacheBehaviour })
}
@@ -48,6 +56,14 @@ export async function get_search_results_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_many', { ids, cacheBehaviour })
}
export async function get_search_results_v3(id, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_v3', { id, cacheBehaviour })
}
export async function get_search_results_v3_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_v3_many', { ids, cacheBehaviour })
}
export async function purge_cache_types(cacheTypes) {
return await invoke('plugin:cache|purge_cache_types', { cacheTypes })
}
+4 -3
View File
@@ -18,7 +18,7 @@ import { install_to_existing_profile } from '@/helpers/pack.js'
- icon is a path to an image file, which will be copied into the profile directory
*/
export async function create(name, gameVersion, modloader, loaderVersion, icon, skipInstall) {
export async function create(name, gameVersion, modloader, loaderVersion, icon, skipInstall, linkedData) {
//Trim string name to avoid "Unable to find directory"
name = name.trim()
return await invoke('plugin:profile-create|profile_create', {
@@ -28,6 +28,7 @@ export async function create(name, gameVersion, modloader, loaderVersion, icon,
loaderVersion,
icon,
skipInstall,
linkedData,
})
}
@@ -174,8 +175,8 @@ export async function get_pack_export_candidates(profilePath) {
// Run Minecraft using a pathed profile
// Returns PID of child
export async function run(path) {
return await invoke('plugin:profile|profile_run', { path })
export async function run(path, serverAddress = null) {
return await invoke('plugin:profile|profile_run', { path, serverAddress })
}
export async function kill(path) {
+1
View File
@@ -139,4 +139,5 @@ type AppSettings = {
export type InstanceSettingsTabProps = {
instance: GameInstance
offline?: boolean
isMinecraftServer?: boolean
}
+13 -1
View File
@@ -30,6 +30,7 @@ export type ServerWorld = BaseWorld & {
index: number
address: string
pack_status: ServerPackStatus
linked_project_id?: string
}
export type World = SingleplayerWorld | ServerWorld
@@ -140,8 +141,15 @@ export async function add_server_to_profile(
name: string,
address: string,
packStatus: ServerPackStatus,
linkedProjectId?: string,
): Promise<number> {
return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
return await invoke('plugin:worlds|add_server_to_profile', {
path,
name,
address,
packStatus,
linkedProjectId,
})
}
export async function edit_server_in_profile(
@@ -212,6 +220,10 @@ export function isServerWorld(world: World): world is ServerWorld {
return world.type === 'server'
}
export function isLinkedWorld(world: World): boolean {
return world.type === 'server' && !!world.linked_project_id
}
export async function refreshServerData(
serverData: ServerData,
protocolVersion: ProtocolVersion | null,
+26 -20
View File
@@ -5,6 +5,9 @@
"app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers"
},
"app.modal.install-to-play.content-required": {
"message": "Content required"
},
"app.modal.install-to-play.header": {
"message": "Install to play"
},
@@ -14,11 +17,11 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "This server requires mods to play. Click install to set up the required files from Modrinth."
"app.modal.install-to-play.required-modpack": {
"message": "Required modpack"
},
"app.modal.install-to-play.shared-by-today": {
"message": "{name} shared this instance with you today."
"app.modal.install-to-play.server-requires-mods": {
"message": "This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server."
},
"app.modal.install-to-play.shared-instance": {
"message": "Shared instance"
@@ -26,6 +29,9 @@
"app.modal.install-to-play.shared-server-instance": {
"message": "Shared server instance"
},
"app.modal.install-to-play.view-contents": {
"message": "View contents"
},
"app.modal.update-to-play.added-count": {
"message": "{count} added"
},
@@ -83,39 +89,33 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.update-toast.body": {
"app.update-popup.body": {
"message": "Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App."
},
"app.update-toast.body.download-complete": {
"app.update-popup.body.download-complete": {
"message": "Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App."
},
"app.update-toast.body.linux": {
"app.update-popup.body.linux": {
"message": "Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!"
},
"app.update-toast.body.metered": {
"app.update-popup.body.metered": {
"message": "Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it."
},
"app.update-toast.changelog": {
"app.update-popup.changelog": {
"message": "Changelog"
},
"app.update-toast.download": {
"app.update-popup.download": {
"message": "Download ({size})"
},
"app.update-toast.download-page": {
"message": "Download"
"app.update-popup.download-complete": {
"message": "Download complete"
},
"app.update-toast.downloading": {
"message": "Downloading..."
},
"app.update-toast.reload": {
"app.update-popup.reload": {
"message": "Reload"
},
"app.update-toast.title": {
"app.update-popup.title": {
"message": "Update available"
},
"app.update-toast.title.download-complete": {
"message": "Download complete"
},
"app.update.complete-toast.text": {
"message": "Click here to view the changelog."
},
@@ -464,6 +464,12 @@
"instance.settings.tabs.installation.unknown-version": {
"message": "(unknown version)"
},
"instance.settings.tabs.installation.unlink-server.description": {
"message": "This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server."
},
"instance.settings.tabs.installation.unlink-server.title": {
"message": "Unlink from server"
},
"instance.settings.tabs.installation.unlink.button": {
"message": "Unlink instance"
},
+419 -137
View File
@@ -1,23 +1,35 @@
<script setup lang="ts">
import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, SearchIcon } from '@modrinth/assets'
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
import type { Labrinth } from '@modrinth/api-client'
import {
ClipboardCopyIcon,
ExternalIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
SearchIcon,
StopCircleIcon,
} from '@modrinth/assets'
import type { ProjectType, SortType, Tags } from '@modrinth/ui'
import {
ButtonStyled,
Checkbox,
defineMessages,
DropdownSelect,
injectNotificationManager,
LoadingIndicator,
Pagination,
ProjectCard,
ProjectCardList,
SearchFilterControl,
SearchSidebarFilter,
StyledInput,
useSearch,
useServerSearch,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import type { Ref } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { computed, nextTick, onUnmounted, ref, shallowRef, toRaw, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
@@ -26,13 +38,23 @@ import type Instance from '@/components/ui/Instance.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_search_results } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_search_results, get_search_results_v3 } from '@/helpers/cache.js'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import {
get as getInstance,
get_projects as getInstanceProjects,
kill,
list as listInstances,
} from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_server_status } from '@/helpers/worlds'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { getServerAddress, playServerProject, useInstall } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const installStore = useInstall()
const router = useRouter()
const route = useRoute()
@@ -42,15 +64,21 @@ const projectTypes = computed(() => {
})
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories().catch(handleError).then(ref),
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
get_categories()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Category[]>),
get_loaders()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Loader[]>),
get_game_versions()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.GameVersion[]>),
])
const tags: Ref<Tags> = computed(() => ({
gameVersions: availableGameVersions.value as GameVersion[],
loaders: loaders.value as Platform[],
categories: categories.value as Category[],
gameVersions: availableGameVersions.value ?? [],
loaders: loaders.value ?? [],
categories: categories.value ?? [],
}))
type Instance = {
@@ -71,15 +99,18 @@ type InstanceProject = {
const instance: Ref<Instance | null> = ref(null)
const instanceProjects: Ref<InstanceProject[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref([])
const newlyInstalled = ref<string[]>([])
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
await updateInstanceContext()
watch(route, () => {
updateInstanceContext()
})
watch(
() => [route.query.i, route.query.ai, route.path],
() => {
updateInstanceContext()
},
)
async function updateInstanceContext() {
if (route.query.i) {
@@ -164,7 +195,86 @@ const {
createPageParams,
} = useSearch(projectTypes, tags, instanceFilters)
const serverHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const serverPings = shallowRef<Record<string, number | undefined>>({})
const runningServerProjects = ref<Record<string, string>>({})
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
const packs = await listInstances()
const newRunning: Record<string, string> = {}
for (const hit of hits) {
const inst = packs.find((p: any) => p.linked_data?.project_id === hit.project_id)
if (inst) {
const processes = await get_by_profile_path(inst.path).catch(() => [])
if (Array.isArray(processes) && processes.length > 0) {
newRunning[hit.project_id] = inst.path
}
}
}
runningServerProjects.value = newRunning
}
async function handleStopServerProject(projectId: string) {
const instancePath = runningServerProjects.value[projectId]
if (!instancePath) return
await kill(instancePath).catch(() => {})
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
async function handlePlayServerProject(projectId: string) {
await playServerProject(projectId)
checkServerRunningStates(serverHits.value)
}
function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
const address = getServerAddress(project.minecraft_java_server)
if (!address) return
installStore.showAddServerToInstanceModal(project.name, address)
}
const unlistenProcesses = await process_listener((e: any) => {
if (e.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find(
([, path]) => path === e.profile_path_id,
)?.[0]
if (projectId) {
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
}
})
onUnmounted(() => {
unlistenProcesses()
})
const {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes,
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags, query, maxResults, currentPage })
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
for (const hit of hits) {
const address = hit.minecraft_java_server?.address
if (!address) continue
get_server_status(address)
.then((status) => {
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
})
.catch((err) => {
console.error(`Failed to ping server ${address}:`, err)
})
}
}
const previousFilterState = ref('')
const isRefreshing = ref(false)
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -179,20 +289,14 @@ breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: rout
const loading = ref(true)
const projectType = ref(route.params.projectType)
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
watch(projectType, () => {
loading.value = true
})
type SearchResult = {
project_id: string
}
type SearchResults = {
total_hits: number
limit: number
hits: SearchResult[]
interface SearchResults extends Labrinth.Search.v2.SearchResults {
hits: (Labrinth.Search.v2.ResultSearchProject & { installed?: boolean })[]
}
const results: Ref<SearchResults | null> = shallowRef(null)
@@ -200,73 +304,125 @@ const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
watch(requestParams, () => {
const effectiveRequestParams = computed(() => {
return projectType.value === 'server' ? serverRequestParams.value : requestParams.value
})
watch(effectiveRequestParams, async () => {
if (!route.params.projectType) return
await nextTick()
refreshSearch()
})
async function refreshSearch() {
let rawResults = await get_search_results(requestParams.value)
if (!rawResults) {
rawResults = {
result: {
if (isRefreshing.value) return
isRefreshing.value = true
try {
const isServer = projectType.value === 'server'
if (isServer) {
const rawResults = (await get_search_results_v3(serverRequestParams.value)) as {
result: Labrinth.Search.v3.SearchResults
} | null
const searchResults = rawResults?.result ?? { hits: [], total_hits: 0 }
const hits = searchResults.hits ?? []
serverHits.value = hits
serverPings.value = {}
pingServerHits(hits)
checkServerRunningStates(hits)
results.value = {
hits: [],
total_hits: 0,
limit: 1,
},
total_hits: searchResults.total_hits ?? 0,
limit: maxResults.value,
offset: 0,
}
} else {
let rawResults = (await get_search_results(requestParams.value)) as {
result: SearchResults
} | null
if (!rawResults) {
rawResults = {
result: {
hits: [],
total_hits: 0,
limit: 1,
offset: 0,
},
}
}
if (instance.value) {
const installedProjectIds = new Set([
...newlyInstalled.value,
...Object.values(instanceProjects.value ?? {})
.filter((x) => x.metadata)
.map((x) => x.metadata.project_id),
])
rawResults.result.hits = rawResults.result.hits.map((val) => ({
...val,
installed: installedProjectIds.has(val.project_id),
}))
}
results.value = rawResults.result
}
}
if (instance.value) {
for (const val of rawResults.result.hits) {
val.installed =
newlyInstalled.value.includes(val.project_id) ||
Object.values(instanceProjects.value).some(
(x) => x.metadata && x.metadata.project_id === val.project_id,
)
const currentFilterState = JSON.stringify({
query: query.value,
filters: toRaw(currentFilters.value),
sort: toRaw(currentSortType.value),
maxResults: maxResults.value,
projectTypes: toRaw(projectTypes.value),
})
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
currentPage.value = 1
}
}
results.value = rawResults.result
const currentFilterState = JSON.stringify({
query: query.value,
filters: currentFilters.value,
sort: currentSortType.value,
maxResults: maxResults.value,
projectTypes: projectTypes.value,
})
previousFilterState.value = currentFilterState
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
currentPage.value = 1
}
const persistentParams: LocationQuery = {}
previousFilterState.value = currentFilterState
const persistentParams: LocationQuery = {}
for (const [key, value] of Object.entries(route.query)) {
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
persistentParams[key] = value
for (const [key, value] of Object.entries(route.query)) {
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
persistentParams[key] = value
}
}
}
if (instanceHideInstalled.value) {
persistentParams.ai = 'true'
} else {
delete persistentParams.ai
}
if (instanceHideInstalled.value) {
persistentParams.ai = 'true'
} else {
delete persistentParams.ai
}
const params = {
...persistentParams,
...createPageParams(),
}
const params = {
...persistentParams,
...(isServer ? createServerPageParams() : createPageParams()),
}
breadcrumbs.setContext({
name: 'Discover content',
link: `/browse/${projectType.value}`,
query: params,
})
await router.replace({ path: route.path, query: params })
loading.value = false
breadcrumbs.setContext({
name: 'Discover content',
link: `/browse/${projectType.value}`,
query: params,
})
const queryString = Object.entries(params)
.flatMap(([key, value]) => {
const values = Array.isArray(value) ? value : [value]
return values
.filter((v): v is string => v != null)
.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`)
})
.join('&')
const newUrl = `${route.path}${queryString ? '?' + queryString : ''}`
window.history.replaceState(window.history.state, '', newUrl)
} catch (err) {
console.error('Error refreshing search:', err)
} finally {
loading.value = false
isRefreshing.value = false
}
}
async function setPage(newPageNumber: number) {
@@ -289,7 +445,7 @@ function clearSearch() {
}
watch(
() => route.params.projectType,
() => route.params.projectType as ProjectType,
async (newType) => {
// Check if the newType is not the same as the current value
if (!newType || newType === projectType.value) return
@@ -308,8 +464,9 @@ const selectableProjectTypes = computed(() => {
if (instance.value) {
if (
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
availableGameVersions.value &&
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
) {
dataPacks = true
}
@@ -338,6 +495,7 @@ const selectableProjectTypes = computed(() => {
{ label: 'Resource Packs', href: `/browse/resourcepack` },
{ label: 'Data Packs', href: `/browse/datapack`, shown: dataPacks },
{ label: 'Shaders', href: `/browse/shader` },
{ label: 'Servers', href: `/browse/server`, shown: !instance.value },
]
if (params) {
@@ -374,9 +532,27 @@ const messages = defineMessages({
},
})
const getServerModpackContent = (project: Labrinth.Search.v3.ResultSearchProject) => {
const content = project.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick: () => {
router.push(`/project/${project_id}`)
},
showCustomModpackTooltip: project_id === project.project_id,
}
}
return undefined
}
const options = ref(null)
const handleRightClick = (event, result) => {
options.value.showMenu(event, result, [
const handleRightClick = (event: any, result: any) => {
// @ts-ignore
options.value?.showMenu(event, result, [
{
name: 'open_link',
},
@@ -385,7 +561,7 @@ const handleRightClick = (event, result) => {
},
])
}
const handleOptionsClick = (args) => {
const handleOptionsClick = (args: any) => {
switch (args.option) {
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
@@ -424,33 +600,54 @@ previousFilterState.value = JSON.stringify({
@click.prevent.stop
/>
</div>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="instanceFilters"
:filter-type="filter"
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3"
inner-panel-class="ml-2 mr-3"
:open-by-default="
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
"
>
<template #header>
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByInstance) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByInstance) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
</SearchSidebarFilter>
<template v-if="projectType === 'server'">
<SearchSidebarFilter
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="serverCurrentFilters"
v-model:toggled-groups="serverToggledGroups"
:provided-filters="[]"
:filter-type="filterType"
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3"
inner-panel-class="ml-2 mr-3"
:open-by-default="true"
>
<template #header>
<h3 class="text-base m-0">{{ filterType.formatted_name }}</h3>
</template>
</SearchSidebarFilter>
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="instanceFilters"
:filter-type="filter"
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3"
inner-panel-class="ml-2 mr-3"
:open-by-default="
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
"
>
<template #header>
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByInstance) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByInstance) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
</SearchSidebarFilter>
</template>
</Teleport>
<div ref="searchWrapper" class="flex flex-col gap-3 p-6">
<template v-if="instance">
@@ -472,11 +669,17 @@ previousFilterState.value = JSON.stringify({
<div class="flex gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
:model-value="projectType === 'server' ? serverCurrentSortType : currentSortType"
class="max-w-[16rem]"
name="Sort by"
:options="sortTypes as any"
:options="(projectType === 'server' ? serverSortTypes : sortTypes) as any"
:display-name="(option: SortType | undefined) => option?.display"
@update:model-value="
(v: SortType) => {
if (projectType === 'server') serverCurrentSortType = v
else currentSortType = v
}
"
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
@@ -494,6 +697,14 @@ previousFilterState.value = JSON.stringify({
<Pagination :page="currentPage" :count="pageCount" class="ml-auto" @switch-page="setPage" />
</div>
<SearchFilterControl
v-if="projectType === 'server'"
v-model:selected-filters="serverCurrentFilters"
:filters="serverFilterTypes"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="instanceFilters"
@@ -504,36 +715,107 @@ previousFilterState.value = JSON.stringify({
<section v-if="loading" class="offline">
<LoadingIndicator />
</section>
<section v-else-if="offline && results.total_hits === 0" class="offline">
<section v-else-if="offline && results?.total_hits === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
</section>
<section
v-else-if="
projectType === 'server'
? serverHits.length === 0
: results && results.hits && results.hits.length === 0
"
class="offline"
>
No results found for your query!
</section>
<ProjectCardList v-else :layout="'list'">
<SearchCard
v-for="result in results.hits"
:key="result?.project_id"
:project-type="projectType"
:project="result"
:instance="instance"
:categories="[
...categories.filter(
(cat) =>
result?.display_categories.includes(cat.name) && cat.project_type === projectType,
),
...loaders.filter(
(loader) =>
result?.display_categories.includes(loader.name) &&
loader.supported_project_types?.includes(projectType),
),
]"
:installed="result.installed || newlyInstalled.includes(result.project_id)"
@install="
(id) => {
newlyInstalled.push(id)
}
"
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
/>
<template v-if="projectType === 'server'">
<ProjectCard
v-for="project in serverHits"
:key="`server-card-${project.project_id}`"
:title="project.name"
:icon-url="project.icon_url || undefined"
:summary="project.summary"
:tags="project.categories"
:link="`/project/${project.slug ?? project.project_id}`"
:server-online-players="project.minecraft_java_server?.ping?.data?.players_online ?? 0"
:server-region-code="project.minecraft_server?.country"
:server-recent-plays="project.minecraft_java_server?.verified_plays_4w ?? 0"
:server-modpack-content="getServerModpackContent(project)"
:server-ping="serverPings[project.project_id]"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:hide-online-players-label="true"
:hide-recent-plays-label="true"
layout="list"
:max-tags="2"
is-server-project
exclude-loaders
@contextmenu.prevent.stop="
(event: any) =>
handleRightClick(event, { project_type: 'server', slug: project.slug })
"
>
<template #actions>
<div class="flex gap-2">
<ButtonStyled circular>
<button
v-tooltip="'Add server to instance'"
@click.stop="() => handleAddServerToInstance(project)"
>
<PlusIcon />
</button>
</ButtonStyled>
<ButtonStyled
v-if="runningServerProjects[project.project_id]"
color="red"
type="outlined"
>
<button @click="() => handleStopServerProject(project.project_id)">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" type="outlined">
<button
:disabled="
(installStore.installingServerProjects as string[]).includes(
project.project_id,
)
"
@click="() => handlePlayServerProject(project.project_id)"
>
<PlayIcon />
{{
(installStore.installingServerProjects as string[]).includes(
project.project_id,
)
? 'Installing...'
: 'Play'
}}
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</template>
<template v-else>
<SearchCard
v-for="result in results?.hits ?? []"
:key="result?.project_id"
:project-type="projectType"
:project="result"
:instance="instance ?? undefined"
:installed="result.installed || newlyInstalled.includes(result.project_id || '')"
@install="
(id) => {
newlyInstalled.push(id)
}
"
@contextmenu.prevent.stop="(event: any) => handleRightClick(event, result)"
/>
</template>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
+292 -101
View File
@@ -1,36 +1,95 @@
<template>
<div>
<div
class="p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
>
<div v-if="instance">
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
<button @click="updateToPlayModal.show()">Update to play modal</button>
</ButtonStyled>
<ContentPageHeader>
<template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
<Avatar
:src="icon ? icon : undefined"
:alt="instance.name"
size="64px"
:tint-by="instance.path"
/>
</template>
<template #title>
{{ instance.name }}
</template>
<template #summary> </template>
<template #stats>
<div
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
>
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="flex items-center gap-2 font-semibold">
<TimerIcon class="h-6 w-6 text-secondary" />
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
<div class="flex items-center flex-wrap gap-2">
<template v-if="!isServerInstance">
<div class="flex items-center gap-2 capitalize font-medium">
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex items-center gap-2 font-medium">
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
<div v-if="linkedProjectV3" 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="instance.path"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</template>
<template v-else>
<ServerOnlinePlayers :online="playersOnline ?? 0" :status-online="statusOnline" />
<div
v-if="playersOnline !== undefined && (minecraftServer?.country || ping)"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerRegion v-if="minecraftServer?.country" :region="minecraftServer?.country" />
<ServerPing v-if="ping" :ping="ping" />
<div
v-if="modpackContentProjectV3 && (minecraftServer?.country || 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="instance.path"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</template>
<template v-else> Never played </template>
</div>
</template>
<template #actions>
@@ -63,7 +122,7 @@
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false"
v-else-if="playing === false && loading === false && !isServerInstance"
color="brand"
size="large"
>
@@ -72,6 +131,44 @@
Play
</button>
</ButtonStyled>
<div
v-else-if="playing === false && loading === false && isServerInstance"
class="joined-buttons"
>
<ButtonStyled color="brand" size="large">
<button @click="handlePlayServer()">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled color="brand" size="large">
<OverflowMenu
:options="[
{
id: 'join_server',
action: () => handlePlayServer(),
},
{
id: 'launch_instance',
action: () => startInstance('InstancePage'),
},
]"
>
<div class="w-0 text-xl relative top-0.5 right-2.5">
<DropdownIcon />
</div>
<template #join_server>
<PlayIcon />
Join server
</template>
<template #launch_instance>
<PlayIcon />
Launch instance
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled
v-else-if="loading === true && playing === false"
color="brand"
@@ -79,21 +176,23 @@
>
<button disabled>Loading...</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
<ButtonStyled circular size="large">
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" type="transparent" circular>
<ButtonStyled type="transparent" circular size="large">
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
action: () => {
if (instance) showProfileInFolder(instance.path)
},
},
{
id: 'export-mrpack',
action: () => $refs.exportModal.show(),
action: () => exportModal?.show(),
},
]"
>
@@ -112,7 +211,11 @@
<NavTabs :links="tabs" />
</div>
<div v-if="!!instance" class="p-6 pt-4">
<RouterView v-slot="{ Component }" :key="instance.path">
<RouterView
v-if="route.path.startsWith('/instance')"
v-slot="{ Component }"
:key="instance.path"
>
<template v-if="Component">
<Suspense
:key="instance.path"
@@ -127,6 +230,7 @@
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
@@ -160,16 +264,17 @@
</ContextMenu>
</div>
</template>
<script setup>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClipboardCopyIcon,
DownloadIcon,
DropdownIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GameIcon,
GlobeIcon,
HashIcon,
MoreVerticalIcon,
@@ -179,7 +284,6 @@ import {
ServerIcon,
SettingsIcon,
StopCircleIcon,
TimerIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
@@ -191,6 +295,9 @@ import {
injectNotificationManager,
LoadingIndicator,
OverflowMenu,
ServerOnlinePlayers,
ServerPing,
ServerRegion,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
@@ -205,20 +312,21 @@ import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.v
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { get_project_v3, get_version, get_version_many } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import { finish_install, get, get_full_path, get_projects, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils.js'
import { get_server_status } from '@/helpers/worlds'
import { handleSevereError } from '@/store/error.js'
import { playServerProject } from '@/store/install.js'
import { useBreadcrumbs, useLoading } from '@/store/state'
import { useTheming } from '@/store/theme'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const themeStore = useTheming()
const route = useRoute()
const router = useRouter()
@@ -232,38 +340,93 @@ window.addEventListener('online', () => {
offline.value = false
})
const instance = ref()
const modrinthVersions = ref([])
const instance = ref<GameInstance>()
const modrinthVersions = ref<any[]>([])
const playing = ref(false)
const loading = ref(false)
const updateToPlayModal = ref()
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const isServerInstance = ref(false)
const hasContent = ref(true)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const modpackContentProjectV3 = ref<Labrinth.Projects.v3.Project | null>(null)
const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const statusOnline = computed(() => !!javaServerPingData.value)
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
async function fetchInstance() {
instance.value = await get(route.params.id).catch(handleError)
isServerInstance.value = false
linkedProjectV3.value = undefined
modpackContentProjectV3.value = null
modrinthVersions.value = []
hasContent.value = true
ping.value = undefined
if (!offline.value && instance.value.linked_data && instance.value.linked_data.project_id) {
get_project(instance.value.linked_data.project_id, 'must_revalidate')
.catch(handleError)
.then((project) => {
if (project && project.versions) {
get_version_many(project.versions, 'must_revalidate')
.catch(handleError)
.then((versions) => {
modrinthVersions.value = versions.sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
)
})
instance.value = await get(route.params.id as string).catch(handleError)
if (!offline.value && instance.value?.linked_data && instance.value.linked_data.project_id) {
try {
linkedProjectV3.value = await get_project_v3(
instance.value.linked_data.project_id,
'must_revalidate',
)
if (linkedProjectV3.value && linkedProjectV3.value.versions) {
const versions = await get_version_many(linkedProjectV3.value.versions, 'must_revalidate')
modrinthVersions.value = versions.sort(
(a: any, b: any) => dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
)
if (linkedProjectV3.value?.minecraft_server != null) {
isServerInstance.value = true
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (serverAddress) {
get_server_status(serverAddress)
.then((status) => {
if (status.ping != null) {
ping.value = status.ping
playersOnline.value = status.players?.online
}
})
.catch((err) => {
console.error(`Failed to ping server ${serverAddress}:`, err)
})
}
await fetchModpackContent()
const projects = await get_projects(instance.value!.path).catch(() => ({}))
hasContent.value = Object.keys(projects).length > 0
}
})
}
} catch (error: any) {
handleError(error)
}
}
await updatePlayState()
}
async function updatePlayState() {
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
async function fetchModpackContent() {
modpackContentProjectV3.value = null
const versionId = instance.value?.linked_data?.version_id
if (!versionId) return
playing.value = runningProcesses.length > 0
const contentVersion = await get_version(versionId, 'bypass')
const projectId = contentVersion?.project_id
if (projectId) {
modpackContentProjectV3.value = await get_project_v3(projectId, 'bypass')
}
}
async function updatePlayState() {
const runningProcesses = await get_by_profile_path(route.params.id as string).catch(handleError)
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
}
await fetchInstance()
@@ -276,61 +439,74 @@ watch(
},
)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
const tabs = computed(() => [
{
label: 'Content',
href: `${basePath.value}`,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
},
])
const tabs = computed(() => {
const allTabs = []
if (!isServerInstance.value || hasContent.value) {
allTabs.push({ label: 'Content', href: `${basePath.value}` })
}
allTabs.push({ label: 'Worlds', href: `${basePath.value}/worlds` })
allTabs.push({ label: 'Logs', href: `${basePath.value}/logs` })
return allTabs
})
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
watch(
[isServerInstance, hasContent],
() => {
if (isServerInstance.value && !hasContent.value && route.path === basePath.value) {
router.replace(`${basePath.value}/worlds`)
}
},
{ immediate: true },
)
breadcrumbs.setContext({
name: instance.value.name,
link: route.path,
query: route.query,
})
if (instance.value) {
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
)
breadcrumbs.setContext({
name: instance.value.name,
link: route.path,
query: route.query,
})
}
const loadingBar = useLoading()
const options = ref(null)
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {
if (!instance.value) return
if (updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value)
return
}
const startInstance = async (context) => {
loading.value = true
try {
await run(route.params.id)
await run(route.params.id as string)
playing.value = true
} catch (err) {
handleSevereError(err, { profilePath: route.params.id })
handleSevereError(err, { profilePath: route.params.id as string })
}
loading.value = false
trackEvent('InstanceStart', {
trackEvent('InstancePlay', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
}
const stopInstance = async (context) => {
const stopInstance = async (context: string) => {
playing.value = false
await kill(route.params.id).catch(handleError)
await kill(route.params.id as string).catch(handleError)
if (!instance.value) return
trackEvent('InstanceStop', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -338,11 +514,22 @@ const stopInstance = async (context) => {
})
}
const handlePlayServer = async () => {
if (!instance.value?.linked_data?.project_id) return
loading.value = true
try {
await playServerProject(instance.value.linked_data.project_id)
} finally {
await updatePlayState()
loading.value = false
}
}
const repairInstance = async () => {
await finish_install(instance.value).catch(handleError)
}
const handleRightClick = (event) => {
const handleRightClick = (event: MouseEvent) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
@@ -351,7 +538,7 @@ const handleRightClick = (event) => {
{ name: 'copy_path' },
]
options.value.showMenu(
options.value?.showMenu(
event,
instance.value,
playing.value
@@ -372,7 +559,7 @@ const handleRightClick = (event) => {
)
}
const handleOptionsClick = async (args) => {
const handleOptionsClick = async (args: { option: string; item: unknown }) => {
switch (args.option) {
case 'play':
await startInstance('InstancePageContextMenu')
@@ -382,27 +569,29 @@ const handleOptionsClick = async (args) => {
break
case 'add_content':
await router.push({
path: `/browse/${instance.value.loader === 'vanilla' ? 'datapack' : 'mod'}`,
path: `/browse/${instance.value?.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: route.params.id },
})
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(route.params.id)}/options`,
path: `/instance/${encodeURIComponent(route.params.id as string)}/options`,
})
break
case 'open_folder':
await showProfileInFolder(instance.value.path)
if (instance.value) await showProfileInFolder(instance.value.path)
break
case 'copy_path': {
const fullPath = await get_full_path(instance.value.path)
await navigator.clipboard.writeText(fullPath)
if (instance.value) {
const fullPath = await get_full_path(instance.value?.path)
await navigator.clipboard.writeText(fullPath)
}
break
}
}
}
const unlistenProfiles = await profile_listener(async (event) => {
const unlistenProfiles = await profile_listener(async (event: any) => {
if (event.profile_path_id === route.params.id) {
if (event.event === 'removed') {
await router.push({
@@ -410,24 +599,26 @@ const unlistenProfiles = await profile_listener(async (event) => {
})
return
}
instance.value = await get(route.params.id).catch(handleError)
instance.value = await get(route.params.id as string).catch(handleError)
}
})
const unlistenProcesses = await process_listener((e) => {
const unlistenProcesses = await process_listener((e: any) => {
if (e.event === 'finished' && e.profile_path_id === route.params.id) {
playing.value = false
}
})
const icon = computed(() =>
instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
const settingsModal = ref()
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
const timePlayed = computed(() => {
return instance.value.recent_time_played + instance.value.submitted_time_played
return instance.value
? instance.value.recent_time_played + instance.value.submitted_time_played
: 0
})
const timePlayedHumanized = computed(() => {
@@ -278,7 +278,12 @@ import {
useVIntl,
} from '@modrinth/ui'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
import type {
Organization,
Project,
TeamMember,
Version
} from '@modrinth/utils'
import { formatProjectType } from '@modrinth/utils'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { useStorage } from '@vueuse/core'
@@ -323,6 +328,7 @@ const props = defineProps<{
playing: boolean
versions: Version[]
installed: boolean
isServerInstance?: boolean
}>()
type ProjectListEntryAuthor = {
@@ -352,6 +358,7 @@ type ProjectListEntry = {
const isPackLocked = computed(() => {
return props.instance.linked_data && props.instance.linked_data.locked
})
const canUpdatePack = computed(() => {
if (!props.instance.linked_data || !props.versions || !props.versions[0]) return false
return props.instance.linked_data.version_id !== props.versions[0].id
@@ -80,9 +80,13 @@
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
isLinkedWorld(world)
? undefined
: world.type === 'server'
? editServerModal?.show(world)
: editWorldModal?.show(world)
"
@delete="() => promptToRemoveWorld(world)"
@delete="() => !isLinkedWorld(world) && promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/>
</div>
@@ -150,6 +154,7 @@ import {
handleDefaultProfileUpdateEvent,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
isLinkedWorld,
type ProfileEvent,
type ProtocolVersion,
refreshServerData,
@@ -166,6 +171,7 @@ import {
start_join_singleplayer_world,
type World,
} from '@/helpers/worlds.ts'
import { playServerProject } from '@/store/install'
const { handleError } = injectNotificationManager()
const route = useRoute()
@@ -328,6 +334,9 @@ async function joinWorld(world: World) {
startingInstance.value = true
worldPlaying.value = world
if (world.type === 'server') {
if (isLinkedWorld(world)) {
playServerProject(world.linked_project_id)
}
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
@@ -10,7 +10,7 @@ defineProps({
</script>
<template>
<GridDisplay
v-if="instances.length > 0"
v-if="instances && instances.length > 0"
label="Instances"
:instances="instances.filter((i) => !i.linked_data)"
/>
@@ -10,7 +10,7 @@ defineProps({
</script>
<template>
<GridDisplay
v-if="instances.length > 0"
v-if="instances && instances.length > 0"
label="Instances"
:instances="instances.filter((i) => i.linked_data)"
/>
@@ -47,7 +47,7 @@ onUnmounted(() => {
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<template v-if="instances.length > 0">
<template v-if="instances && instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
<div v-else class="no-instance">
@@ -9,5 +9,5 @@ defineProps({
})
</script>
<template>
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
<GridDisplay v-if="instances && instances.length > 0" label="Instances" :instances="instances" />
</template>
@@ -1,6 +1,6 @@
<template>
<div class="gallery">
<Card v-for="(image, index) in project.gallery" :key="image.url" class="gallery-item">
<Card v-for="(image, index) in filteredGallery" :key="image.url" class="gallery-item">
<a @click="expandImage(image, index)">
<img :src="image.url" :alt="image.title" class="gallery-image" />
</a>
@@ -64,14 +64,14 @@
<ContractIcon v-else aria-hidden="true" />
</Button>
<Button
v-if="project.gallery.length > 1"
v-if="filteredGallery.length > 1"
class="previous"
icon-only
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</Button>
<Button v-if="project.gallery.length > 1" class="next" icon-only @click="nextImage()">
<Button v-if="filteredGallery.length > 1" class="next" icon-only @click="nextImage()">
<RightArrowIcon aria-hidden="true" />
</Button>
</div>
@@ -92,11 +92,13 @@ import {
XIcon,
} from '@modrinth/assets'
import { Button, Card } from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { trackEvent } from '@/helpers/analytics'
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const props = defineProps({
project: {
type: Object,
@@ -104,6 +106,10 @@ const props = defineProps({
},
})
const filteredGallery = computed(() =>
props.project.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
const expandedGalleryItem = ref(null)
const expandedGalleryIndex = ref(0)
const zoomedIn = ref(false)
@@ -115,10 +121,10 @@ const hideImage = () => {
const nextImage = () => {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= props.project.gallery.length) {
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = props.project.gallery[expandedGalleryIndex.value]
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
trackEvent('GalleryImageNext', {
project_id: props.project.id,
url: expandedGalleryItem.value.url,
@@ -128,9 +134,9 @@ const nextImage = () => {
const previousImage = () => {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = props.project.gallery.length - 1
expandedGalleryIndex.value = filteredGallery.value.length - 1
}
expandedGalleryItem.value = props.project.gallery[expandedGalleryIndex.value]
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
trackEvent('GalleryImagePrevious', {
project_id: props.project.id,
url: expandedGalleryItem.value,
+229 -14
View File
@@ -1,13 +1,31 @@
<template>
<div>
<InstallToPlayModal ref="installToPlayModal" :project="data" />
<div v-if="data">
<Teleport to="#sidebar-teleport-target">
<ProjectSidebarCompatibility
v-if="!isServerProject"
:project="data"
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
:v3-metadata="projectV3"
class="project-sidebar-section"
/>
<ProjectSidebarServerInfo
v-if="isServerProject"
:project-v3="projectV3"
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
:required-content="serverRequiredContent"
:recommended-version="serverRecommendedVersion"
:supported-versions="serverSupportedVersions"
:loaders="serverModpackLoaders"
:ping="serverPing"
:status-online="serverStatusOnline"
class="project-sidebar-section"
/>
<ProjectSidebarLinks
link-target="_blank"
:project="data"
:project-v3="projectV3"
class="project-sidebar-section"
/>
<ProjectSidebarLinks link-target="_blank" :project="data" class="project-sidebar-section" />
<ProjectSidebarCreators
:organization="null"
:members="members"
@@ -16,17 +34,16 @@
link-target="_blank"
class="project-sidebar-section"
/>
<ProjectSidebarTags :project="data" class="project-sidebar-section" />
<ProjectSidebarDetails
:project="data"
:has-versions="versions.length > 0"
:link-target="`_blank`"
:hide-license="isServerProject"
class="project-sidebar-section"
/>
</Teleport>
<div class="flex flex-col gap-4 p-6">
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
<button @click="installToPlayModal.show()">Install to play modal</button>
</ButtonStyled>
<InstanceIndicator v-if="instance" :instance="instance" />
<template v-if="data">
<Teleport
@@ -35,7 +52,67 @@
>
<ProjectBackgroundGradient :project="data" />
</Teleport>
<ProjectHeader :project="data" @contextmenu.prevent.stop="handleRightClick">
<ServerProjectHeader
v-if="isServerProject"
:project="data"
:project-v3="projectV3"
:ping="serverPing"
@contextmenu.prevent.stop="handleRightClick"
>
<template #actions>
<ButtonStyled v-if="serverPlaying" size="large" color="red">
<button @click="handleStopServer">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
<button
:disabled="data && installStore.installingServerProjects.includes(data.id)"
@click="handleClickPlay"
>
<PlayIcon />
{{
data && installStore.installingServerProjects.includes(data.id)
? 'Installing...'
: 'Play'
}}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<button v-tooltip="'Add server to instance'" @click="handleAddServerToInstance">
<PlusIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="`More options`"
:options="[
{
id: 'open-in-browser',
link: `https://modrinth.com/project/${data.slug}`,
external: true,
},
{
divider: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
</ServerProjectHeader>
<ProjectHeader v-else :project="data" @contextmenu.prevent.stop="handleRightClick">
<template #actions>
<ButtonStyled size="large" color="brand">
<button
@@ -103,6 +180,7 @@
query: instanceFilters,
},
subpages: ['version'],
shown: projectV3?.minecraft_server == null,
},
{
label: 'Gallery',
@@ -112,6 +190,7 @@
]"
/>
<RouterView
v-if="route.path.startsWith('/project')"
:project="data"
:versions="versions"
:members="members"
@@ -142,7 +221,10 @@ import {
GlobeIcon,
HeartIcon,
MoreVerticalIcon,
PlayIcon,
PlusIcon,
ReportIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
@@ -154,22 +236,43 @@ import {
ProjectSidebarCreators,
ProjectSidebarDetails,
ProjectSidebarLinks,
ProjectSidebarServerInfo,
ProjectSidebarTags,
ServerProjectHeader,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, ref, shallowRef, watch } from 'vue'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import {
get_project,
get_project_v3,
get_team,
get_version,
get_version_many,
} from '@/helpers/cache.js'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import {
get as getInstance,
get_projects as getInstanceProjects,
kill,
list as listInstances,
} from '@/helpers/profile'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_server_status } from '@/helpers/worlds'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { install as installVersion } from '@/store/install.js'
import {
getServerAddress,
install as installVersion,
playServerProject,
useInstall,
} from '@/store/install.js'
import { useTheming } from '@/store/state.js'
dayjs.extend(relativeTime)
@@ -180,6 +283,7 @@ const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const installStore = useInstall()
const installing = ref(false)
const data = shallowRef(null)
const versions = shallowRef([])
@@ -190,8 +294,16 @@ const instanceProjects = ref(null)
const installed = ref(false)
const installedVersion = ref(null)
const installToPlayModal = ref()
const isServerProject = ref(false)
const projectV3 = shallowRef(null)
const serverRequiredContent = shallowRef(null)
const serverRecommendedVersion = shallowRef(null)
const serverSupportedVersions = shallowRef([])
const serverModpackLoaders = shallowRef([])
const serverPing = ref(undefined)
const serverStatusOnline = ref(false)
const serverInstancePath = ref(null)
const serverPlaying = ref(false)
const instanceFilters = computed(() => {
if (!instance.value) {
@@ -216,8 +328,41 @@ const [allLoaders, allGameVersions] = await Promise.all([
get_game_versions().catch(handleError).then(ref),
])
async function handleClickPlay() {
if (!isServerProject.value) return
await playServerProject(data.value.id).catch(handleError)
await updateServerPlayState()
}
async function updateServerPlayState() {
if (!isServerProject.value || !data.value) return
const packs = await listInstances()
const inst = packs.find((p) => p.linked_data?.project_id === data.value.id)
if (inst) {
serverInstancePath.value = inst.path
const processes = await get_by_profile_path(inst.path).catch(() => [])
serverPlaying.value = Array.isArray(processes) && processes.length > 0
} else {
serverInstancePath.value = null
serverPlaying.value = false
}
}
async function handleStopServer() {
if (!serverInstancePath.value) return
await kill(serverInstancePath.value).catch(() => {})
serverPlaying.value = false
}
function handleAddServerToInstance() {
const address = getServerAddress(projectV3.value?.minecraft_java_server)
if (!address || !data.value) return
installStore.showAddServerToInstanceModal(data.value.title, address)
}
async function fetchProjectData() {
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
projectV3.value = await get_project_v3(route.params.id, 'must_revalidate').catch(handleError)
if (!project) {
handleError('Error loading project')
@@ -245,11 +390,81 @@ async function fetchProjectData() {
installedVersion.value = installedFile.metadata.version_id
}
}
isServerProject.value = projectV3.value?.minecraft_server != null
// Ping server for latency
const serverAddress = projectV3.value?.minecraft_java_server?.address
serverStatusOnline.value = !!projectV3.value?.minecraft_java_server?.ping?.data
if (serverAddress) {
serverPing.value = undefined
get_server_status(serverAddress)
.then((status) => {
if (status.ping != null) {
serverPing.value = status.ping
}
})
.catch((err) => {
console.error(`Failed to ping server ${serverAddress}:`, err)
})
}
// Fetch server sidebar data (modpack version + project)
const content = projectV3.value?.minecraft_java_server?.content
if (content?.kind === 'modpack' && content.version_id) {
const modpackVersion = await get_version(content.version_id, 'bypass').catch(handleError)
if (modpackVersion) {
serverRecommendedVersion.value = modpackVersion.game_versions?.[0] ?? null
serverModpackLoaders.value = modpackVersion.mrpack_loaders ?? []
if (modpackVersion.project_id) {
const modpackProject = await get_project_v3(
modpackVersion.project_id,
'must_revalidate',
).catch(handleError)
if (modpackProject) {
serverRequiredContent.value = {
name: modpackProject.name,
versionNumber: modpackVersion.version_number ?? '',
icon: modpackProject.icon_url,
onclickName:
modpackProject.id !== project.id
? () => router.push(`/project/${modpackProject.id}`)
: undefined,
onclickVersion:
modpackProject.id !== project.id
? () => router.push(`/project/${modpackProject.id}/version/${modpackVersion.id}`)
: undefined,
}
}
}
}
} else if (content?.kind === 'vanilla') {
serverRecommendedVersion.value = content.recommended_game_version ?? null
const supported = content.supported_game_versions ?? []
serverSupportedVersions.value = supported.filter((v) => !!v)
}
breadcrumbs.setName('Project', data.value.title)
await updateServerPlayState()
}
await fetchProjectData()
const unlistenProcesses = await process_listener((e) => {
if (
e.event === 'finished' &&
serverInstancePath.value &&
e.profile_path_id === serverInstancePath.value
) {
serverPlaying.value = false
}
})
onUnmounted(() => {
unlistenProcesses()
})
watch(
() => route.params.id,
async () => {
@@ -0,0 +1,30 @@
import { AbstractPopupNotificationManager, type PopupNotification } from '@modrinth/ui'
import { type Ref, ref } from 'vue'
export class AppPopupNotificationManager extends AbstractPopupNotificationManager {
private readonly state: Ref<PopupNotification[]>
public constructor() {
super()
this.state = ref<PopupNotification[]>([])
}
public getNotifications(): PopupNotification[] {
return this.state.value
}
protected addNotificationToStorage(notification: PopupNotification): void {
this.state.value.push(notification)
}
protected removeNotificationFromStorage(id: string | number): void {
const index = this.state.value.findIndex((n) => n.id === id)
if (index > -1) {
this.state.value.splice(index, 1)
}
}
protected clearAllNotificationsFromStorage(): void {
this.state.value.splice(0)
}
}
+350 -3
View File
@@ -2,22 +2,42 @@ import dayjs from 'dayjs'
import { defineStore } from 'pinia'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import { get_project, get_project_v3, get_version, get_version_many } from '@/helpers/cache.js'
import {
install_to_existing_profile,
create_profile_and_install as packInstall,
} from '@/helpers/pack.js'
import {
add_project_from_version,
check_installed,
create,
edit,
edit_icon,
get,
get_projects,
install as installProfile,
list,
remove_project,
} from '@/helpers/profile.js'
import {
add_server_to_profile,
edit_server_in_profile,
get_profile_worlds,
start_join_server,
} from '@/helpers/worlds.ts'
import router from '@/routes.js'
import { handleSevereError } from '@/store/error.js'
export const useInstall = defineStore('installStore', {
state: () => ({
installConfirmModal: null,
modInstallModal: null,
incompatibilityWarningModal: null,
installToPlayModal: null,
updateToPlayModal: null,
popupNotificationManager: null,
installingServerProjects: [],
addServerToInstanceModal: null,
}),
actions: {
setInstallConfirmModal(ref) {
@@ -38,6 +58,38 @@ export const useInstall = defineStore('installStore', {
showModInstallModal(project, versions, onInstall) {
this.modInstallModal.show(project, versions, onInstall)
},
setInstallToPlayModal(ref) {
this.installToPlayModal = ref
},
showInstallToPlayModal(projectV3, modpackVersionId, onInstallComplete) {
this.installToPlayModal.show(projectV3, modpackVersionId, onInstallComplete)
},
setUpdateToPlayModal(ref) {
this.updateToPlayModal = ref
},
showUpdateToPlayModal(instance, activeVersionId, onUpdateComplete) {
this.updateToPlayModal.show(instance, activeVersionId, onUpdateComplete)
},
setPopupNotificationManager(manager) {
this.popupNotificationManager = manager
},
setAddServerToInstanceModal(ref) {
this.addServerToInstanceModal = ref
},
showAddServerToInstanceModal(serverName, serverAddress) {
this.addServerToInstanceModal.show(serverName, serverAddress)
},
startInstallingServer(projectId) {
if (!this.installingServerProjects.includes(projectId)) {
this.installingServerProjects.push(projectId)
}
},
stopInstallingServer(projectId) {
this.installingServerProjects = this.installingServerProjects.filter((id) => id !== projectId)
},
isServerInstalling(projectId) {
return this.installingServerProjects.includes(projectId)
},
},
})
@@ -88,8 +140,9 @@ export const install = async (
createInstanceCallback = () => {},
) => {
const project = await get_project(projectId, 'must_revalidate')
const projectV3 = await get_project_v3(projectId, 'must_revalidate')
if (project.project_type === 'modpack') {
if (project.project_type === 'modpack' || projectV3?.minecraft_server != null) {
const version = versionId ?? project.versions[project.versions.length - 1]
const packs = await list()
@@ -220,3 +273,297 @@ export const installVersionDependencies = async (profile, version) => {
}
}
}
/**
* Server projects that use modpack content use have linked_data.project_id as
* the server project id and linked_data.version_id as the modpack content version id
*
* The modpack content version can be of the same server project, or from a different project
*/
export const installServerProject = async (serverProjectId) => {
const [project, projectV3] = await Promise.all([
get_project(serverProjectId, 'must_revalidate'),
get_project_v3(serverProjectId, 'must_revalidate'),
])
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const content = projectV3?.minecraft_java_server?.content
if (!content || content.kind !== 'modpack') return
const contentVersionId = content.version_id
const contentVersion = await get_version(contentVersionId, 'bypass')
const contentProjectId = contentVersion.project_id
const gameVersion = contentVersion.game_versions?.[0] ?? ''
const profilePath = await create(
project.title,
gameVersion,
'vanilla',
null,
project.icon_url,
true,
{
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
)
// Save the icon path before pack install overwrites it
const profileBeforeInstall = await get(profilePath)
const originalIconPath = profileBeforeInstall?.icon_path ?? null
await install_to_existing_profile(contentProjectId, contentVersionId, project.title, profilePath)
// Pack install overwrites name, icon, and linked_data with the content project's values.
// Restore them to point to the server project.
await edit(profilePath, {
name: project.title,
linked_data: {
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
})
await edit_icon(profilePath, originalIconPath)
await syncServerAsWorld(profilePath, project.title, serverAddress, serverProjectId)
}
export const getServerAddress = (javaServer) => {
if (!javaServer) return null
const { address, port } = javaServer
return port !== 25565 ? `${address}:${port}` : address
}
const syncServerAsWorld = async (
profilePath,
serverName,
serverAddress,
serverProjectId = null,
) => {
if (!profilePath || !serverAddress) return
try {
const worlds = await get_profile_worlds(profilePath)
if (serverProjectId) {
// Check if a linked world for this project already exists
const linkedWorld = worlds.find(
(w) => w.type === 'server' && w.linked_project_id === serverProjectId,
)
if (linkedWorld) {
// Sync linked world data with project details
if (linkedWorld.address !== serverAddress || linkedWorld.name !== serverName) {
await edit_server_in_profile(
profilePath,
linkedWorld.index,
serverName,
serverAddress,
linkedWorld.pack_status,
)
}
return
}
}
const alreadyExists = worlds.some((w) => w.type === 'server' && w.address === serverAddress)
if (!alreadyExists) {
await add_server_to_profile(
profilePath,
serverName,
serverAddress,
'prompt',
serverProjectId ?? undefined,
)
}
} catch (err) {
console.error('Failed to add server to instance worlds:', err)
}
}
const joinServer = async (profilePath, serverAddress, serverProjectId) => {
if (!serverAddress) return
await start_join_server(profilePath, serverAddress)
}
const findInstalledInstance = async (projectId) => {
const packs = await list()
return packs.find((pack) => pack.linked_data?.project_id === projectId) ?? null
}
const createVanillaInstance = async (project, gameVersion, serverAddress) => {
const profilePath = await create(
project.title,
gameVersion,
'fabric',
'latest',
project.icon_url,
false,
{
project_id: project.id,
version_id: '',
locked: true,
},
)
await syncServerAsWorld(profilePath, project.title, serverAddress, project.id)
return profilePath
}
const updateVanillaGameVersion = async (instance, targetGameVersion) => {
if (instance.game_version === targetGameVersion) return
await edit(instance.path, { game_version: targetGameVersion })
await installProfile(instance.path, false)
}
const showModpackInstallSuccess = (installStore, project, serverAddress) => {
installStore.popupNotificationManager?.addPopupNotification({
title: 'Install complete',
text: `${project.name} is installed and ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
await joinServer(
project.path,
serverAddress,
project.linked_data?.project_id ?? null,
)
} catch (err) {
handleSevereError(err, { profilePath: project.path })
}
},
color: 'brand',
},
]
: []),
{
label: 'Instance',
action: () => router.push(`/instance/${encodeURIComponent(project.path)}`),
},
],
autoCloseMs: null,
})
}
const showUpdateSuccess = (installStore, instance, serverAddress) => {
installStore.popupNotificationManager?.addPopupNotification({
title: 'Update complete',
text: `${instance.name} has been updated and is ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
if (serverAddress) await start_join_server(instance.path, serverAddress)
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
},
color: 'brand',
},
]
: []),
{
label: 'Instance',
action: () => router.push(`/instance/${encodeURIComponent(instance.path)}`),
},
],
autoCloseMs: null,
})
}
/**
* Handles logic when clicking "Play" on a server project. This includes:
* - Checking if need to install modpack content. If so, opens install to play modal
* - Checking if need to update modpack content. If so, open update to play modal
* - Checking if need to create instance for vanilla server. If so, creates instance.
* - Adding server to worlds list if not already there
* - Joining server
*/
export const playServerProject = async (projectId) => {
const installStore = useInstall()
const [project, projectV3] = await Promise.all([
get_project(projectId, 'must_revalidate'),
get_project_v3(projectId, 'must_revalidate'),
])
if (projectV3?.minecraft_server == null) {
console.warn('playServerProject failed: project is not a server project')
}
const content = projectV3?.minecraft_java_server?.content
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const isVanilla = content?.kind === 'vanilla'
const isModpack = content?.kind === 'modpack'
const modpackVersionId = content?.version_id ?? null
const recommendedGameVersion = content?.recommended_game_version
let instance = await findInstalledInstance(project.id)
if (isVanilla && !instance) {
if (installStore.installingServerProjects.includes(projectId)) return
installStore.startInstallingServer(projectId)
try {
const path = await createVanillaInstance(project, recommendedGameVersion, serverAddress)
if (path) {
instance = await get(path)
showModpackInstallSuccess(installStore, instance, serverAddress)
}
} finally {
installStore.stopInstallingServer(projectId)
}
return
}
if (isModpack && !instance) {
installStore.showInstallToPlayModal(projectV3, modpackVersionId, async () => {
const newInstance = await findInstalledInstance(project.id)
if (!newInstance) return
// Ensure the server is in the worlds list after modpack install
await syncServerAsWorld(newInstance.path, project.title, serverAddress, project.id)
showModpackInstallSuccess(installStore, newInstance, serverAddress)
})
return
}
if (!instance) return
await syncServerAsWorld(instance.path, project.title, serverAddress, project.id)
// Update existing instance if needed
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {
installStore.showUpdateToPlayModal(instance, modpackVersionId, () => {
showUpdateSuccess(installStore, instance, serverAddress)
})
return
}
if (isVanilla && instance.game_version !== recommendedGameVersion) {
if (installStore.installingServerProjects.includes(projectId)) return
installStore.startInstallingServer(projectId)
try {
await updateVanillaGameVersion(instance, recommendedGameVersion)
showUpdateSuccess(installStore, instance, serverAddress)
} finally {
installStore.stopInstallingServer(projectId)
}
return
}
// join server
try {
await joinServer(instance.path, serverAddress, project.id)
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
}
+4
View File
@@ -29,6 +29,8 @@ fn main() {
.commands(&[
"get_project",
"get_project_many",
"get_project_v3",
"get_project_v3_many",
"get_version",
"get_version_many",
"get_user",
@@ -39,6 +41,8 @@ fn main() {
"get_organization_many",
"get_search_results",
"get_search_results_many",
"get_search_results_v3",
"get_search_results_v3_many",
"purge_cache_types",
])
.default_permission(
+7 -1
View File
@@ -30,11 +30,13 @@ macro_rules! impl_cache_methods {
impl_cache_methods!(
(Project, Project),
(ProjectV3, ProjectV3),
(Version, Version),
(User, User),
(Team, Vec<TeamMember>),
(Organization, Organization),
(SearchResults, SearchResults)
(SearchResults, SearchResults),
(SearchResultsV3, SearchResultsV3)
);
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
@@ -42,6 +44,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
get_project,
get_project_many,
get_project_v3,
get_project_v3_many,
get_version,
get_version_many,
get_user,
@@ -52,6 +56,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_organization_many,
get_search_results,
get_search_results_many,
get_search_results_v3,
get_search_results_v3_many,
purge_cache_types,
])
.build()
+10 -2
View File
@@ -6,6 +6,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use theseus::prelude::*;
use theseus::profile::QuickPlayType;
use theseus::server_address::ServerAddress;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("profile")
@@ -250,8 +251,15 @@ pub async fn profile_get_pack_export_candidates(
// for the actual Child in the state.
// invoke('plugin:profile|profile_run', path)
#[tauri::command]
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
let process = profile::run(path, QuickPlayType::None).await?;
pub async fn profile_run(
path: &str,
server_address: Option<String>,
) -> Result<ProcessMetadata> {
let quick_play = match server_address {
Some(addr) => QuickPlayType::Server(ServerAddress::Unresolved(addr)),
None => QuickPlayType::None,
};
let process = profile::run(path, quick_play).await?;
Ok(process)
}
+2 -1
View File
@@ -20,6 +20,7 @@ pub async fn profile_create(
loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
icon: Option<String>, // the icon for the profile
skip_install: Option<bool>,
linked_data: Option<LinkedData>,
) -> Result<String> {
let res = profile::create::profile_create(
name,
@@ -27,7 +28,7 @@ pub async fn profile_create(
modloader,
loader_version,
icon,
None,
linked_data,
skip_install,
)
.await?;
+9 -2
View File
@@ -151,11 +151,18 @@ pub async fn add_server_to_profile(
name: String,
address: String,
pack_status: ServerPackStatus,
linked_project_id: Option<String>,
) -> Result<usize> {
let path = get_full_path(path).await?;
Ok(
worlds::add_server_to_profile(&path, name, address, pack_status)
.await?,
worlds::add_server_to_profile(
&path,
name,
address,
pack_status,
linked_project_id,
)
.await?,
)
}
@@ -34,7 +34,7 @@
}
.value {
color: var(--color-text-dark);
color: var(--color-text-primary);
font-weight: bold;
font-size: 2rem;
}
@@ -99,7 +99,7 @@
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
// Same styling as h3
color: var(--color-text-dark);
color: var(--color-text-primary);
font-size: 1.17rem;
font-weight: bold;
@@ -381,7 +381,7 @@
}
&:active {
color: var(--color-text-dark);
color: var(--color-text-primary);
}
}
@@ -1113,7 +1113,7 @@ svg.inline-svg {
display: flex;
flex-direction: column;
gap: var(--gap-12);
padding: var(--gap-16) var(--gap-24);
padding: var(--gap-16);
h2 {
font-size: var(--text-18);
+3 -3
View File
@@ -380,18 +380,18 @@ a {
}
h1 {
color: var(--color-text-dark);
color: var(--color-text-primary);
}
h2 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--color-text-dark);
color: var(--color-text-primary);
}
h3 {
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
color: var(--color-text-dark);
color: var(--color-text-primary);
}
input {
@@ -114,28 +114,19 @@ iframe[id^='google_ads_iframe'] {
color: var(--color-base);
}
#qc-cmp2-ui::before {
background: var(--color-raised-bg);
}
#qc-cmp2-ui::before,
#qc-cmp2-ui::after {
background: var(--color-raised-bg);
}
#qc-cmp2-ui button[mode='primary'] {
#qc-cmp2-ui button[mode='primary'],
#qc-cmp2-ui button[mode='secondary'] {
background: var(--color-brand);
color: var(--color-accent-contrast);
border-radius: var(--radius-lg);
border: none;
}
#qc-cmp2-ui button[mode='secondary'] {
background: var(--color-button-bg);
color: var(--color-base);
border-radius: var(--radius-lg);
border: none;
}
#qc-cmp2-ui button[mode='link'] {
color: var(--color-link);
}
+9 -1
View File
@@ -1,5 +1,6 @@
<template>
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="{ 'card-shadow': mode === 'navigation' }"
@@ -299,7 +300,14 @@ watch(
},
)
watch(() => props.links, updateActiveTab, { deep: true })
watch(
() => props.links,
async () => {
await nextTick()
updateActiveTab()
},
{ deep: true },
)
</script>
<style scoped>
@@ -1,6 +1,6 @@
<template>
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<div v-if="!noHeader" class="flex items-center justify-between">
<span class="font-semibold text-contrast">
Minecraft versions <span class="text-red">*</span>
</span>
@@ -45,6 +45,7 @@
versionType === 'all' && !group.isReleaseGroup ? 'w-max' : 'w-16',
modelValue.includes(version) ? '!text-contrast' : '',
]"
:disabled="disabled"
@click="() => handleToggleVersion(version)"
@blur="
() => {
@@ -76,6 +77,8 @@ type GameVersion = Labrinth.Tags.v2.GameVersion
const props = defineProps<{
modelValue: string[]
gameVersions: Labrinth.Tags.v2.GameVersion[]
noHeader?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
@@ -158,7 +161,7 @@ function groupVersions(gameVersions: GameVersion[]) {
const groups: Record<string, string[]> = {}
let currentGroupKey
let currentGroupKey: string
gameVersions.forEach((gameVersion) => {
if (gameVersion.version_type === 'release') {
@@ -1,12 +1,35 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)">
<div class="min-w-md flex max-w-md flex-col gap-3">
<NewModal
ref="modal"
:header="
projectType === 'server'
? formatMessage(messages.serverProjectTitle)
: formatMessage(messages.title)
"
:max-width="'550px'"
>
<div class="flex w-full flex-col gap-6">
<CreateLimitAlert v-model="hasHitLimit" type="project" />
<div class="flex flex-col gap-2">
<div v-if="showTypeSelector" class="flex flex-col gap-2.5">
<label for="project-type">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.typeLabel) }}
</span>
</label>
<Combobox
id="project-type"
v-model="projectType"
name="project-type"
:options="projectTypeOptions"
:disabled="hasHitLimit"
/>
</div>
<div class="flex flex-col gap-2.5">
<label for="name">
<span class="text-lg font-semibold text-contrast">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.nameLabel) }}
<span class="text-brand-red">*</span>
</span>
</label>
<StyledInput
@@ -19,32 +42,46 @@
@update:model-value="updatedName()"
/>
</div>
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<label for="slug" class="flex flex-col gap-2.5">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
</span>
<div class="text-input-wrapper !w-full">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<StyledInput
id="slug"
v-model="slug"
:maxlength="64"
class="w-full"
type="text"
autocomplete="off"
:disabled="hasHitLimit"
@update:model-value="manualSlug = true"
/>
</div>
</label>
<div class="flex flex-col gap-2.5">
<label for="owner">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.ownerLabel) }}
</span>
</label>
<Combobox
id="owner"
v-model="owner"
name="owner"
:options="[userOption, ...ownerOptions]"
searchable
:disabled="hasHitLimit"
show-icon-in-selected
/>
<span>{{ formatMessage(messages.ownerDescription) }}</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2.5">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(commonMessages.visibilityLabel) }}
<span class="text-brand-red">*</span>
</span>
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
</label>
<Chips
id="visibility"
@@ -54,14 +91,13 @@
:capitalize="false"
:disabled="hasHitLimit"
/>
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2.5">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.summaryLabel) }}
<span class="text-brand-red">*</span>
</span>
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</label>
<StyledInput
id="additional-information"
@@ -71,8 +107,9 @@
:placeholder="formatMessage(messages.summaryPlaceholder)"
:disabled="hasHitLimit"
/>
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</div>
<div class="flex justify-end gap-2">
<div class="flex justify-end gap-2.5">
<ButtonStyled class="w-24">
<button @click="cancel">
<XIcon aria-hidden="true" />
@@ -80,7 +117,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand" class="w-32">
<button :disabled="hasHitLimit" @click="createProject">
<button v-tooltip="missingFieldsTooltip" :disabled="disableCreate" @click="createProject">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createProject) }}
</button>
@@ -90,30 +127,77 @@
</NewModal>
</template>
<script setup>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, defineAsyncComponent, h } from 'vue'
import CreateLimitAlert from './CreateLimitAlert.vue'
type ProjectTypes = 'server' | 'project'
interface VisibilityOption {
actual: Labrinth.Projects.v2.ProjectStatus
display: string
}
interface ShowOptions {
type?: 'server' | 'project'
showTypeSelector?: boolean
}
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const router = useRouter()
defineExpose({
show,
})
const auth = (await useAuth()) as Ref<{
user: { id: string; username: string; avatar_url: string } | null
}>
const messages = defineMessages({
title: {
id: 'create.project.title',
defaultMessage: 'Creating a project',
},
serverProjectTitle: {
id: 'create.project.server-project-title',
defaultMessage: 'Creating a server project',
},
typeLabel: {
id: 'create.project.type-label',
defaultMessage: 'Type',
},
typeProject: {
id: 'create.project.type-project',
defaultMessage: 'Project',
},
typeServer: {
id: 'create.project.type-server',
defaultMessage: 'Server',
},
ownerLabel: {
id: 'create.project.owner-label',
defaultMessage: 'Owner',
},
ownerDescription: {
id: 'create.project.owner-description',
defaultMessage: `Set the project owner as yourself or an organization you're a member of.`,
},
nameLabel: {
id: 'create.project.name-label',
defaultMessage: 'Name',
@@ -146,6 +230,10 @@ const messages = defineMessages({
id: 'create.project.create-project',
defaultMessage: 'Create project',
},
createServerProject: {
id: 'create.project.create-server-project',
defaultMessage: 'Create server',
},
visibilityPublic: {
id: 'create.project.visibility-public',
defaultMessage: 'Public',
@@ -158,24 +246,39 @@ const messages = defineMessages({
id: 'create.project.visibility-private',
defaultMessage: 'Private',
},
})
const props = defineProps({
organizationId: {
type: String,
required: false,
default: null,
missingFieldsTooltip: {
id: 'create.project.missing-fields-tooltip',
defaultMessage: 'Missing fields: {fields}',
},
})
const modal = ref()
const props = defineProps<{
organizationId?: string | null
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const hasHitLimit = ref(false)
const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
const visibilities = ref([
const projectType = ref<ProjectTypes>('project')
const showTypeSelector = ref(false)
const projectTypeOptions = computed<ComboboxOption<ProjectTypes>[]>(() => [
{
value: 'project',
label: formatMessage(messages.typeProject),
},
{
value: 'server',
label: formatMessage(messages.typeServer),
},
])
const ownerOptions = ref<ComboboxOption<string>[]>([])
const owner = ref<string | undefined>('self')
const organizations = ref<Labrinth.Projects.v3.Organization[]>([])
const visibilities = ref<VisibilityOption[]>([
{
actual: 'approved',
display: formatMessage(messages.visibilityPublic),
@@ -189,10 +292,84 @@ const visibilities = ref([
display: formatMessage(messages.visibilityPrivate),
},
])
const visibility = ref(visibilities.value[0])
const visibility = ref<VisibilityOption>(visibilities.value[0])
const disableCreate = computed(() => {
if (hasHitLimit.value) return true
if (!name.value.trim() || !slug.value.trim()) return true
if (description.value.trim().length < 3) return true
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
return true
return false
})
const missingFieldsTooltip = computed(() => {
const missingFields = []
if (!name.value.trim()) missingFields.push(formatMessage(messages.nameLabel))
if (!slug.value.trim()) missingFields.push(formatMessage(messages.urlLabel))
if (description.value.trim().length < 3) missingFields.push(formatMessage(messages.summaryLabel))
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
missingFields.push(formatMessage(messages.ownerLabel))
if (missingFields.length === 0) return ''
return formatMessage(messages.missingFieldsTooltip, {
fields: missingFields.join(', '),
})
})
const cancel = () => {
modal.value.hide()
modal.value?.hide()
}
const userOption = computed(() => ({
value: 'self',
label: auth.value.user?.username || 'Unknown user',
icon: auth.value.user?.avatar_url
? defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: auth.value.user?.avatar_url,
alt: 'User Avatar',
class: 'h-5 w-5 rounded',
}),
}),
)
: undefined,
}))
const { labrinth } = injectModrinthClient()
async function fetchOrganizations() {
if (!auth.value.user?.id) return
try {
const orgs = (await useBaseFetch(`user/${auth.value.user.id}/organizations`, {
apiVersion: 3,
})) as Labrinth.Projects.v3.Organization[]
organizations.value = orgs || []
ownerOptions.value = organizations.value.map((org) => ({
value: org.id,
label: org.name,
icon: org.icon_url
? defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: org.icon_url,
alt: `${org.name} Icon`,
class: 'h-5 w-5 rounded',
}),
}),
)
: undefined,
}))
if (props.organizationId) owner.value = props.organizationId
} catch (err) {
console.error('Failed to fetch organizations:', err)
}
}
async function createProject() {
@@ -200,9 +377,7 @@ async function createProject() {
const formData = new FormData()
const auth = await useAuth()
const projectData = {
const projectData: Labrinth.Projects.v2.CreateProjectBase = {
title: name.value.trim(),
project_type: 'mod',
slug: slug.value,
@@ -212,8 +387,8 @@ async function createProject() {
initial_versions: [],
team_members: [
{
user_id: auth.value.user.id,
name: auth.value.user.username,
user_id: auth.value.user?.id,
name: auth.value.user?.username,
role: 'Owner',
},
],
@@ -224,45 +399,71 @@ async function createProject() {
is_draft: true,
}
if (props.organizationId) {
projectData.organization_id = props.organizationId
}
formData.append('data', JSON.stringify(projectData))
try {
await useBaseFetch('project', {
method: 'POST',
body: formData,
headers: {
'Content-Disposition': formData,
},
})
let createdProjectId: string | undefined
modal.value.hide()
if (projectType.value === 'server') {
const result = await labrinth.projects_v3.createServerProject({
base: {
name: projectData.title,
slug: projectData.slug,
summary: projectData.description,
description: '',
requested_status: projectData.requested_status,
organization_id: owner.value !== 'self' ? owner.value : undefined,
},
minecraft_server: {
// empty component
},
minecraft_java_server: {
address: '',
port: 25565,
},
minecraft_bedrock_server: {
address: '',
port: 19132,
},
})
createdProjectId = result.id
} else {
const result = (await useBaseFetch('project', {
method: 'POST',
body: formData,
headers: {
'Content-Disposition': formData as unknown as string,
},
})) as Labrinth.Projects.v3.Project
createdProjectId = result.id
console.log(createdProjectId)
}
modal.value?.hide()
await router.push(`/project/${slug.value}/settings`)
} catch (err) {
} catch (err: unknown) {
const error = err as { data?: { description?: string } }
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
text: error.data?.description ?? String(err),
type: 'error',
})
}
stopLoading()
}
function show(event) {
async function show(event?: MouseEvent, options?: ShowOptions) {
name.value = ''
slug.value = ''
description.value = ''
manualSlug.value = false
modal.value.show(event)
owner.value = 'self'
projectType.value = options?.type ?? 'project'
showTypeSelector.value = options?.showTypeSelector ?? false
await fetchOrganizations()
modal.value?.show(event)
}
defineExpose({
show,
})
function updatedName() {
if (!manualSlug.value) {
slug.value = name.value
@@ -100,6 +100,7 @@ interface Tags {
interface Props {
project: Labrinth.Projects.v2.Project
projectV3: Labrinth.Projects.v3.Project
versions?: Labrinth.Versions.v2.Version[]
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
@@ -172,6 +173,7 @@ const emit = defineEmits<{
const nagContext = computed<NagContext>(() => ({
project: props.project,
projectV3: props.projectV3,
versions: props.versions,
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
currentRoute: props.routeName,
@@ -1654,7 +1654,7 @@ function shouldShowStage(stage: Stage): boolean {
function shouldShowAction(action: Action): boolean {
if (typeof action.shouldShow === 'function') {
return action.shouldShow(projectV2.value)
return action.shouldShow(projectV2.value, projectV3.value)
}
return true
@@ -1663,7 +1663,7 @@ function shouldShowAction(action: Action): boolean {
function getVisibleDropdownOptions(action: DropdownAction) {
return action.options.filter((option) => {
if (typeof option.shouldShow === 'function') {
return option.shouldShow(projectV2.value)
return option.shouldShow(projectV2.value, projectV3.value)
}
return true
})
@@ -1672,7 +1672,7 @@ function getVisibleDropdownOptions(action: DropdownAction) {
function getVisibleMultiSelectOptions(action: MultiSelectChipsAction) {
return action.options.filter((option) => {
if (typeof option.shouldShow === 'function') {
return option.shouldShow(projectV2.value)
return option.shouldShow(projectV2.value, projectV3.value)
}
return true
})
@@ -0,0 +1,303 @@
<template>
<div>
<div class="flex flex-col gap-3">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="text-xl font-semibold text-contrast">Server compatibility</div>
<div v-if="!content" class="text-sm text-secondary">
Select whether your server is vanilla or modded and which versions it supports. The
Modrinth App uses this when a player joins.
</div>
<div v-else>
<div v-if="content.kind === 'vanilla'" class="flex items-center gap-1.5">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
>
<BoxIcon class="h-4 w-4 shrink-0 text-secondary" />
</div>
Vanilla server
</div>
<div
v-else-if="content.kind === 'modpack' && !usingCustomMrpack"
class="flex items-center gap-1.5"
>
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
>
<PackageIcon class="h-4 w-4 shrink-0 text-secondary" />
</div>
Published modpack
</div>
<div v-else class="flex items-center gap-1.5">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
>
<PackagePlusIcon class="h-4 w-4 shrink-0 text-secondary" />
</div>
Custom modpack
</div>
</div>
</div>
<ButtonStyled v-if="content" type="outlined">
<button class="!border-[1px]" @click="handleSwitchCompatibility">
<ArrowLeftRightIcon />
Switch type
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button @click="handleSetCompatibility">
<ComponentIcon />
Set compatibility
</button>
</ButtonStyled>
</div>
<div
v-if="content"
class="flex justify-between rounded-2xl border border-solid border-surface-5 p-4"
>
<!-- kind = vanilla -->
<div
v-if="content?.kind === 'vanilla'"
class="flex flex-col items-start justify-between gap-2.5"
>
<div class="flex flex-col gap-2">
<div class="font-medium text-secondary">Recommended version</div>
<div class="text-2xl font-semibold text-contrast">
{{ content.recommended_game_version ?? '—' }}
</div>
</div>
<div class="flex flex-col gap-2">
<div class="font-medium text-secondary">Supported versions</div>
<div class="flex flex-wrap gap-1.5">
<TagItem
v-for="v in formatVersionsForDisplay(
content.supported_game_versions,
tags.gameVersions,
)"
:key="v"
>
{{ v }}
</TagItem>
<div v-if="!content.supported_game_versions.length">—</div>
</div>
</div>
</div>
<!-- kind = modpack -->
<div
v-if="content?.kind === 'modpack' && modpackProject"
class="flex w-full max-w-[500px] flex-col items-start justify-between gap-4"
>
<div class="flex w-full flex-col gap-2">
<div class="font-medium text-secondary">Required modpack</div>
<div class="w-fullitems-center flex gap-3 rounded-2xl bg-surface-1 p-3">
<Avatar
v-if="!usingCustomMrpack"
:src="modpackProject.icon_url"
size="56px"
:tint-by="modpackProject.name"
/>
<div
v-else
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-solid border-surface-5 bg-surface-3"
>
<PackagePlusIcon class="h-10 w-10 shrink-0 text-secondary" />
</div>
<div class="flex flex-col justify-center gap-1">
<div class="font-semibold text-contrast">
<NuxtLink
v-if="!usingCustomMrpack"
:to="`/modpack/${modpackProject.slug}`"
class="hover:underline"
>
{{ modpackProject.name }}
</NuxtLink>
<span v-else>{{ modpackFileName }}</span>
</div>
<div class="flex h-6 items-center gap-1.5 text-secondary">
<NuxtLink
v-if="modpackOrg?.name"
:to="`/organization/${modpackOrg.slug}`"
class="flex items-center gap-1 hover:underline"
>
<Avatar
v-if="modpackOrg?.icon_url"
:src="modpackOrg.icon_url"
size="24px"
class="rounded-2xl"
/>
{{ modpackOrg.name }}
</NuxtLink>
<div
v-if="modpackOrg?.name && modpackVersion"
class="h-1.5 w-1.5 rounded-full bg-surface-5"
></div>
<NuxtLink
v-if="modpackVersion && !usingCustomMrpack"
:to="`/modpack/${modpackProject?.slug}/version/${modpackVersion.id}`"
class="hover:underline"
>
v{{ modpackVersion.version_number }}
</NuxtLink>
<div v-else-if="modpackVersion" class="flex items-center gap-1.5">
<div>v{{ modpackVersion.version_number }}</div>
<div class="h-1.5 w-1.5 rounded-full bg-surface-5"></div>
<a
v-if="modpackVersion.files?.length"
:href="
modpackVersion.files.find((f) => f.primary)?.url ??
modpackVersion.files[0]?.url
"
class="flex items-center gap-0.5 hover:underline"
>
<DownloadIcon />
Download
</a>
</div>
</div>
</div>
</div>
</div>
<div v-if="modpackVersion" class="flex flex-col gap-2">
<div class="font-medium text-secondary">Required version</div>
<div class="flex flex-wrap gap-1.5">
<TagItem v-for="gv in modpackVersion.game_versions" :key="gv">
{{ gv }}
</TagItem>
<TagItem
v-for="loader in modpackVersion.mrpack_loaders"
:key="loader"
:style="`--_color: var(--color-platform-${loader})`"
>
<component
:is="getLoaderIcon(loader)"
v-if="getLoaderIcon(loader)"
class="h-4 w-4"
/>
<FormattedTag :tag="loader" enforce-type="loader" />
</TagItem>
</div>
</div>
</div>
<ButtonStyled v-if="content">
<button class="!w-full !max-w-[160px]" @click="handleUpdateContent">
<RefreshCwIcon />
Update
</button>
</ButtonStyled>
</div>
</div>
<ServerCompatibilityModal ref="serverCompatibilityModal" />
</div>
</template>
<script setup lang="ts">
import {
ArrowLeftRightIcon,
BoxIcon,
ComponentIcon,
DownloadIcon,
getLoaderIcon,
PackageIcon,
PackagePlusIcon,
RefreshCwIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
FormattedTag,
injectModrinthClient,
injectProjectPageContext,
TagItem,
} from '@modrinth/ui'
import { formatVersionsForDisplay } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useGeneratedState } from '~/composables/generated'
import ServerCompatibilityModal from './ServerCompatibilityModal/ServerCompatibilityModal.vue'
const serverCompatibilityModal = useTemplateRef<InstanceType<typeof ServerCompatibilityModal>>(
'serverCompatibilityModal',
)
const { projectV3 } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const tags = useGeneratedState()
const content = computed(() => {
if (!projectV3.value) return null
const content = projectV3.value.minecraft_java_server?.content
if (!content) return null
if (content?.kind === 'vanilla' && !content.recommended_game_version) {
return null
}
return content
})
const modpackVersionId = computed(() => {
if (content.value?.kind === 'modpack') return content.value.version_id
return null
})
const { data: modpackVersion } = useQuery({
queryKey: computed(() => ['versions', 'detail', modpackVersionId.value]),
queryFn: () => labrinth.versions_v3.getVersion(modpackVersionId.value!),
enabled: computed(() => !!modpackVersionId.value),
})
const modpackProjectId = computed(() => modpackVersion.value?.project_id ?? null)
const { data: modpackProject } = useQuery({
queryKey: computed(() => ['project', 'v3', modpackProjectId.value]),
queryFn: () => labrinth.projects_v3.get(modpackProjectId.value!),
enabled: computed(() => !!modpackProjectId.value),
})
const { data: modpackOrg } = useQuery({
queryKey: computed(() => ['project', 'org', modpackProjectId.value]),
queryFn: () => labrinth.projects_v3.getOrganization(modpackProjectId.value!),
enabled: computed(() => !!modpackProjectId.value && !!modpackProject.value?.organization),
})
const usingCustomMrpack = computed(() => modpackVersion.value?.project_id === projectV3.value?.id)
const modpackFileName = computed(() => {
if (!modpackVersion.value?.files?.length) return null
const primary = modpackVersion.value.files.find((f) => f.primary)
return (primary ?? modpackVersion.value.files[0]).filename
})
function handleSetCompatibility() {
serverCompatibilityModal.value?.show()
}
function handleSwitchCompatibility() {
serverCompatibilityModal.value?.show({ isSwitchingCompatibilityType: true })
}
function handleUpdateContent() {
if (!content.value?.kind) return
switch (content.value.kind) {
case 'vanilla':
serverCompatibilityModal.value?.show({ updateContentKind: 'vanilla' })
break
case 'modpack':
if (usingCustomMrpack.value) {
serverCompatibilityModal.value?.show({ updateContentKind: 'custom-modpack' })
} else {
serverCompatibilityModal.value?.show({ updateContentKind: 'published-modpack' })
}
break
default:
break
}
}
</script>
@@ -0,0 +1,14 @@
<template>
<Admonition v-if="isSwitchingCompatibilityType" type="critical" header="Data loss warning">
Changing the compatibility type will reset your previous compatibility settings and redistribute
the new settings to users in the Modrinth App.
</Admonition>
</template>
<script setup lang="ts">
import { Admonition } from '@modrinth/ui'
import { injectServerCompatibilityContext } from '../../../../providers/manage-server-compatibility-modal'
const { isSwitchingCompatibilityType } = injectServerCompatibilityContext()
</script>
@@ -0,0 +1,71 @@
<template>
<MultiStageModal
ref="modal"
:stages="ctx.stageConfigs"
:context="ctx"
:fade="ctx.isSwitchingCompatibilityType.value ? 'danger' : 'standard'"
@hide="handleHide"
/>
</template>
<script setup lang="ts">
import { injectProjectPageContext, MultiStageModal } from '@modrinth/ui'
import type { ComponentExposed } from 'vue-component-type-helpers'
import {
type CompatibilityType,
createServerCompatibilityContext,
provideServerCompatibilityContext,
} from '../../../../providers/manage-server-compatibility-modal'
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const { projectV3 } = injectProjectPageContext()
const ctx = createServerCompatibilityContext(modal)
provideServerCompatibilityContext(ctx)
interface ShowModalOptions {
stageId?: string | null
updateContentKind?: CompatibilityType
isSwitchingCompatibilityType?: boolean
}
async function show(options?: ShowModalOptions) {
if (options?.updateContentKind) {
ctx.compatibilityType.value = options.updateContentKind
ctx.isEditingExistingCompatibility.value = true
// Prefill existing values for vanilla
const content = projectV3.value?.minecraft_java_server?.content
if (options.updateContentKind === 'vanilla' && content && content.kind === 'vanilla') {
ctx.supportedGameVersions.value = content.supported_game_versions ?? []
ctx.recommendedGameVersion.value = content.recommended_game_version ?? null
}
await nextTick()
modal.value?.setStage(1)
} else {
modal.value?.setStage(options?.stageId ?? 0)
}
if (options?.isSwitchingCompatibilityType) {
ctx.isSwitchingCompatibilityType.value = true
}
modal.value?.show()
}
function handleHide() {
ctx.resetContext()
}
function hide() {
handleHide()
modal.value?.hide()
}
defineExpose({
show,
hide,
})
</script>
@@ -0,0 +1,68 @@
<template>
<div class="flex flex-col gap-6">
<DataLossWarningBanner />
<div class="flex flex-col gap-4">
<div class="font-semibold text-contrast">Select compatibility type</div>
<div class="flex w-full flex-col gap-2">
<button
v-for="option in options"
:key="option.value"
class="flex !w-full items-center gap-4 rounded-3xl bg-surface-4 p-3 text-left transition-all hover:brightness-125"
@click="selectType(option.value)"
>
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-solid border-surface-5"
>
<component :is="option.icon" class="h-9 w-9 text-secondary" />
</div>
<div class="flex flex-col gap-0.5">
<span class="font-bold text-contrast">{{ option.label }}</span>
<span class="text-sm text-secondary">{{ option.description }}</span>
</div>
</button>
</div>
<div class="text-sm text-secondary">
Servers with custom modpacks should not be uploaded as separate modpack projects.
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { BoxIcon, PackageIcon, PackagePlusIcon } from '@modrinth/assets'
import {
type CompatibilityType,
injectServerCompatibilityContext,
} from '~/providers/manage-server-compatibility-modal'
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
const ctx = injectServerCompatibilityContext()
const options = [
{
value: 'vanilla' as CompatibilityType,
label: 'Vanilla server',
description: 'A vanilla server with no mods.',
icon: BoxIcon,
},
{
value: 'published-modpack' as CompatibilityType,
label: 'Published modpack',
description: 'A modded server using a published modpack.',
icon: PackageIcon,
},
{
value: 'custom-modpack' as CompatibilityType,
label: 'Custom modpack',
description: 'A modded server using a custom modpack.',
icon: PackagePlusIcon,
},
]
function selectType(type: CompatibilityType) {
ctx.compatibilityType.value = type
ctx.modal.value?.nextStage()
}
</script>
@@ -0,0 +1,139 @@
<template>
<div class="flex flex-col gap-6">
<DataLossWarningBanner />
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Select modpack</div>
<div class="flex flex-col gap-6 rounded-2xl border border-solid border-surface-5 p-4">
<div class="flex flex-col gap-2">
<label class="font-semibold text-contrast">Project</label>
<ProjectCombobox
v-model="selectedProjectId"
:project-types="['modpack']"
:exclude-project-ids="[currentProjectId]"
placeholder="Select modpack"
search-placeholder="Search by name or paste ID..."
loading-message="Loading..."
no-results-message="No results found"
/>
</div>
<div v-if="selectedProjectId" class="flex flex-col gap-2">
<label class="font-semibold text-contrast">Version</label>
<Combobox
v-model="selectedVersionId"
placeholder="Select version"
:options="versionOptions"
:searchable="true"
search-placeholder="Search versions..."
:no-options-message="versionsLoading ? 'Loading...' : 'No results found'"
/>
</div>
</div>
<div v-if="selectedVersion" class="flex flex-col gap-4 rounded-2xl bg-surface-2 p-4">
<div class="flex items-center justify-between">
<div class="text-secondary">Game version</div>
<div class="flex flex-wrap gap-1">
<TagItem v-for="gv in selectedVersion.game_versions" :key="gv">
{{ gv }}
</TagItem>
</div>
</div>
<div
v-if="selectedVersion.mrpack_loaders?.length"
class="flex items-center justify-between"
>
<div class="text-secondary">Platform</div>
<div class="flex flex-wrap gap-1">
<TagItem
v-for="loader in selectedVersion.mrpack_loaders"
:key="loader"
:style="`--_color: var(--color-platform-${loader})`"
>
<component :is="getLoaderIcon(loader)" v-if="getLoaderIcon(loader)" class="h-4 w-4" />
<FormattedTag :tag="loader" enforce-type="loader" />
</TagItem>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getLoaderIcon } from '@modrinth/assets'
import {
Combobox,
FormattedTag,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
ProjectCombobox,
TagItem,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
const { projectV3 } = injectProjectPageContext()
const currentProjectId = computed(() => projectV3.value?.id)
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
interface VersionInfo {
id: string
version_number: string
name: string
}
const versionsLoading = ref(false)
const projectVersions = ref<VersionInfo[]>([])
const versionOptions = computed(() =>
projectVersions.value.map((v) => ({
label: v.version_number,
value: v.id,
})),
)
const { data: selectedVersion } = useQuery({
queryKey: computed(() => ['versions', 'detail', selectedVersionId.value]),
queryFn: () => labrinth.versions_v3.getVersion(selectedVersionId.value),
enabled: computed(() => !!selectedVersionId.value),
})
watch(
() => selectedProjectId.value,
async (newProjectId) => {
selectedVersionId.value = ''
projectVersions.value = []
if (!newProjectId) return
versionsLoading.value = true
try {
const versions = await labrinth.versions_v3.getProjectVersions(newProjectId)
projectVersions.value = versions.map((v) => ({
id: v.id,
version_number: v.version_number,
name: v.name,
}))
} catch (error: unknown) {
const err = error as { data?: { description?: string } }
addNotification({
title: 'Failed to load versions',
text: err.data?.description || String(error),
type: 'error',
})
} finally {
versionsLoading.value = false
}
},
)
</script>
@@ -0,0 +1,64 @@
<template>
<div class="flex w-full flex-col gap-6">
<DataLossWarningBanner />
<div class="flex w-full flex-col gap-4">
<label>
<span class="label__title">Supported MC versions</span>
<McVersionPicker v-model="supportedGameVersions" no-header :game-versions="gameVersions" />
</label>
<div>
<label>
<span class="label__title">Recommended MC version</span>
<Combobox
v-model="recommendedGameVersion"
v-tooltip="
!recommendedOptions.length
? 'Set supported versions before selecting the recommended version'
: undefined
"
:options="recommendedOptions"
searchable
:display-name="(val: string) => val"
placeholder="Select version"
:disabled="!recommendedOptions.length"
/>
<div class="mt-2 text-secondary">
Players joining the server from the Modrinth App will connect using this version.
</div>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Combobox } from '@modrinth/ui'
import { computed, watch } from 'vue'
import McVersionPicker from '~/components/ui/create-project-version/components/McVersionPicker.vue'
import { useGeneratedState } from '~/composables/generated'
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
const { supportedGameVersions, recommendedGameVersion } = injectServerCompatibilityContext()
const generatedState = useGeneratedState()
const gameVersions = generatedState.value.gameVersions
const recommendedOptions = computed(() =>
gameVersions
.filter((v) => v.version_type === 'release')
.filter((v) => supportedGameVersions.value.includes(v.version))
.map((v) => ({ label: v.version, value: v.version })),
)
watch(
() => supportedGameVersions.value,
(supported) => {
if (recommendedGameVersion.value && !supported.includes(recommendedGameVersion.value)) {
recommendedGameVersion.value = null
}
},
)
</script>
@@ -0,0 +1,92 @@
<template>
<div class="flex w-full flex-col gap-6">
<DataLossWarningBanner />
<div class="flex w-full flex-col gap-4">
<div class="font-semibold text-contrast">Upload custom modpack</div>
<DropzoneFileInput
v-if="!ctx.customModpackFile.value"
primary-prompt="Drag and drop your .mrpack file"
secondary-prompt="Or click to browse"
accept=".mrpack"
size="medium"
@change="handleFileUpload"
/>
<div
v-if="ctx.customModpackFile.value"
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-2 text-button-text"
>
<div class="flex items-center gap-2 overflow-hidden">
<FileIcon />
<span
v-tooltip="ctx.customModpackFile.value.name"
class="overflow-hidden text-ellipsis whitespace-nowrap font-medium"
>
{{ ctx.customModpackFile.value.name }}
</span>
</div>
<ButtonStyled size="standard" :circular="true">
<button
v-tooltip="'Replace file'"
aria-label="Replace file"
class="!shadow-none"
@click="fileInput?.click()"
>
<ArrowLeftRightIcon aria-hidden="true" />
<input
ref="fileInput"
class="hidden"
type="file"
accept=".mrpack"
@change="handleFileInputChange"
/>
</button>
</ButtonStyled>
</div>
<Checkbox v-model="ctx.hasLicensePermission.value">
<span class="max-w-[90%] text-left text-primary">
Do you have the appropriate licenses to redistribute all content in this Modpack?
<NuxtLink
to="https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions"
external
target="_blank"
class="font-medium text-blue underline"
>
Learn more
</NuxtLink>
</span>
</Checkbox>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowLeftRightIcon, FileIcon } from '@modrinth/assets'
import { ButtonStyled, Checkbox, DropzoneFileInput } from '@modrinth/ui'
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
const ctx = injectServerCompatibilityContext()
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
function handleFileUpload(files: File[]) {
if (files.length > 0) {
ctx.customModpackFile.value = files[0]
}
}
function handleFileInputChange(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
ctx.customModpackFile.value = file
}
target.value = ''
}
</script>
@@ -38,7 +38,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
newProjectGeneralSettings: false,
newProjectEnvironmentSettings: true,
hideRussiaCensorshipBanner: false,
serverDiscovery: false,
serverDiscovery: true,
disablePrettyProjectUrlRedirects: false,
hidePreviewBanner: false,
i18nDebug: false,
@@ -92,6 +92,11 @@ export const useGeneratedState = () =>
id: 'modpack',
display: 'modpack',
},
{
actual: 'server',
id: 'server',
display: 'server',
},
],
loaderData: {
pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'folia'],
+2
View File
@@ -3,6 +3,8 @@ export const getProjectTypeForUrl = (type, categories) => {
}
export const getProjectTypeForUrlShorthand = (type, categories, overrideTags) => {
if (type === 'minecraft_java_server') return 'server'
const tags = overrideTags ?? useGeneratedState().value
if (type === 'mod') {
+16 -1
View File
@@ -398,6 +398,10 @@
id: 'new-project',
action: (event) => $refs.modal_creation.show(event),
},
{
id: 'new-server-project',
action: (event) => $refs.modal_creation.show(event, { type: 'server' }),
},
{
id: 'new-collection',
action: (event) => $refs.modal_collection_creation.show(event),
@@ -410,10 +414,13 @@
]"
>
<PlusIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
{{ formatMessage(messages.publish) }}
<template #new-project>
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newProject) }}
</template>
<template #new-server-project>
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newServerProject) }}
</template>
<!-- <template #import-project> <BoxImportIcon /> Import project </template>-->
<template #new-collection>
<CollectionIcon aria-hidden="true" /> {{ formatMessage(messages.newCollection) }}
@@ -835,6 +842,10 @@ const messages = defineMessages({
id: 'layout.action.create-new',
defaultMessage: 'Create new...',
},
publish: {
id: 'layout.action.publish',
defaultMessage: 'Publish',
},
reviewProjects: {
id: 'layout.action.review-projects',
defaultMessage: 'Project review',
@@ -867,6 +878,10 @@ const messages = defineMessages({
id: 'layout.action.new-project',
defaultMessage: 'New project',
},
newServerProject: {
id: 'layout.action.new-server-project',
defaultMessage: 'New server',
},
newCollection: {
id: 'layout.action.new-collection',
defaultMessage: 'New collection',
@@ -497,12 +497,27 @@
"create.project.create-project": {
"message": "Create project"
},
"create.project.create-server-project": {
"message": "Create server"
},
"create.project.missing-fields-tooltip": {
"message": "Missing fields: {fields}"
},
"create.project.name-label": {
"message": "Name"
},
"create.project.name-placeholder": {
"message": "Enter project name..."
},
"create.project.owner-description": {
"message": "Set the project owner as yourself or an organization you're a member of."
},
"create.project.owner-label": {
"message": "Owner"
},
"create.project.server-project-title": {
"message": "Creating a server project"
},
"create.project.summary-description": {
"message": "A sentence or two that describes your project."
},
@@ -515,6 +530,15 @@
"create.project.title": {
"message": "Creating a project"
},
"create.project.type-label": {
"message": "Type"
},
"create.project.type-project": {
"message": "Project"
},
"create.project.type-server": {
"message": "Server"
},
"create.project.url-label": {
"message": "URL"
},
@@ -1433,6 +1457,12 @@
"layout.action.new-project": {
"message": "New project"
},
"layout.action.new-server-project": {
"message": "New server"
},
"layout.action.publish": {
"message": "Publish"
},
"layout.action.reports": {
"message": "Review reports"
},
+13 -6
View File
@@ -5,7 +5,16 @@ import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useServerModrinthClient } from '~/server/utils/api-client'
// All valid project type URL segments
const PROJECT_TYPES = ['project', 'mod', 'plugin', 'datapack', 'shader', 'resourcepack', 'modpack']
const PROJECT_TYPES = [
'project',
'mod',
'plugin',
'datapack',
'shader',
'resourcepack',
'modpack',
'server',
]
export default defineNuxtRouteMiddleware(async (to) => {
// Only handle project routes
@@ -23,6 +32,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Fetch v2 project for redirect check AND cache it for the page
// Using fetchQuery ensures the page's useQuery gets this cached result
const project = await queryClient.fetchQuery(projectQueryOptions.v2(projectId, client))
const projectV3 = await queryClient.fetchQuery(projectQueryOptions.v3(projectId, client))
// Let page handle 404
if (!project) return
@@ -35,12 +45,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
queryClient.setQueryData(['project', 'v2', project.id], project)
}
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
// Determine the correct URL type
const correctType = getProjectTypeForUrlShorthand(
project.project_type,
project.loaders,
tags.value,
)
const correctType = getProjectTypeForUrlShorthand(projectType, project.loaders, tags.value)
// Preserve the rest of the path (subpages like /versions, /settings, etc.)
const pathParts = to.path.split('/')
+238 -13
View File
@@ -73,6 +73,7 @@
"
/>
</NewModal>
<OpenInAppModal ref="openInAppModal" />
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
@@ -432,7 +433,14 @@
}"
>
<div class="normal-page__header relative my-4">
<ProjectHeader :project="project" :member="!!currentMember">
<component
:is="isServerProject ? ServerProjectHeader : ProjectHeader"
v-bind="
isServerProject
? { project, projectV3, member: !!currentMember }
: { project, member: !!currentMember }
"
>
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
<nuxt-link
@@ -446,6 +454,7 @@
<div class="hidden sm:contents">
<ButtonStyled
v-if="!isServerProject"
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
@@ -458,8 +467,6 @@
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
@mouseenter="loadVersions"
@focus="loadVersions"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
@@ -468,10 +475,29 @@
}}
</button>
</ButtonStyled>
<ButtonStyled
v-else
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
? `standard`
: `brand`
"
:circular="!!auth.user && !!currentMember"
>
<button
v-tooltip="auth.user && currentMember && !openInAppModal?.open ? 'Play' : ''"
@click="handlePlayServerProject"
>
<PlayIcon aria-hidden="true" />
{{ auth.user && currentMember ? '' : 'Play' }}
</button>
</ButtonStyled>
</div>
<div class="contents sm:hidden">
<ButtonStyled
v-if="!isServerProject"
size="large"
circular
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
@@ -479,13 +505,21 @@
<button
:aria-label="formatMessage(commonMessages.downloadButton)"
class="flex sm:hidden"
@mouseenter="loadVersions"
@focus="loadVersions"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled
v-else
size="large"
circular
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
>
<button aria-label="Play" class="flex sm:hidden" @click="handlePlayServerProject">
<PlayIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<Tooltip
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
@@ -737,7 +771,7 @@
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectHeader>
</component>
<ProjectMemberHeader
v-if="currentMember"
:project="project"
@@ -796,7 +830,19 @@
</div>
<div class="normal-page__sidebar">
<ProjectSidebarServerInfo
v-if="isServerProject"
:project-v3="projectV3"
:tags="tags"
:required-content="serverRequiredContent"
:recommended-version="serverRecommendedVersion"
:supported-versions="serverSupportedVersions"
:loaders="serverModpackLoaders"
:status-online="projectV3?.minecraft_java_server?.ping?.data != null"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarCompatibility
v-if="!isServerProject"
:project="project"
:tags="tags"
:v3-metadata="projectV3"
@@ -805,16 +851,22 @@
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
<ProjectSidebarLinks
:project="project"
:project-v3="projectV3"
:link-target="$external()"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarCreators
v-if="!isServerProject"
:organization="organization"
:members="members"
:org-link="(slug) => `/organization/${slug}`"
:user-link="(username) => `/user/${username}`"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarTags
:project="project"
class="card flex-card experimental-styles-within"
/>
<!-- TODO: Finish license modal and enable -->
<ProjectSidebarDetails
v-if="false"
@@ -827,7 +879,7 @@
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div class="details-list__item">
<div v-if="!isServerProject" class="details-list__item">
<BookTextIcon aria-hidden="true" />
<div>
{{ formatMessage(messages.licensedLabel) }}
@@ -953,6 +1005,7 @@ import {
ListIcon,
ModrinthIcon,
MoreVerticalIcon,
PlayIcon,
PlusIcon,
ReportIcon,
ScaleIcon,
@@ -976,6 +1029,7 @@ import {
injectNotificationManager,
IntlFormatted,
NewModal,
OpenInAppModal,
OverflowMenu,
PopoutMenu,
ProjectBackgroundGradient,
@@ -985,8 +1039,11 @@ import {
ProjectSidebarCreators,
ProjectSidebarDetails,
ProjectSidebarLinks,
ProjectSidebarServerInfo,
ProjectSidebarTags,
provideProjectPageContext,
ScrollablePanel,
ServerProjectHeader,
ServersPromo,
StyledInput,
TagItem,
@@ -1034,6 +1091,7 @@ const user = await useUser()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const cosmetics = useCosmetics()
const router = useNativeRouter()
const { locale, formatMessage } = useVIntl()
@@ -1041,6 +1099,7 @@ const debug = useDebugLogger('DownloadModal')
const settingsModal = ref()
const downloadModal = ref()
const openInAppModal = ref()
const overTheTopDownloadAnimation = ref()
const userSelectedGameVersion = ref(null)
@@ -1051,6 +1110,8 @@ const gameVersionFilterInput = ref()
const versionFilter = ref('')
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
const baseId = useId()
@@ -1121,6 +1182,21 @@ const showVersionsCheckbox = computed(() => {
return false
})
const serverProject = computed(() => ({
name: project.value.title,
slug: project.value.slug || project.value.id,
numPlayers: projectV3.value?.minecraft_java_server?.ping?.data?.players_online,
icon: project.value.icon_url,
statusOnline: !!projectV3.value?.minecraft_java_server?.ping?.data,
region: projectV3.value?.minecraft_server?.country,
}))
function handlePlayServerProject() {
openInAppModal.value?.show({
serverProject: serverProject.value,
})
}
function installWithApp() {
setTimeout(() => {
getModrinthAppAccordion.value.open()
@@ -1543,6 +1619,79 @@ const { data: projectV3, error: _projectV3Error } = useQuery({
enabled: computed(() => !!projectId.value),
})
// Server sidebar: modpack version + project for required content
const serverModpackVersionId = computed(() => {
const content = projectV3.value?.minecraft_java_server?.content
return content?.kind === 'modpack' ? content.version_id : null
})
const { data: serverModpackVersion } = useQuery({
queryKey: computed(() => ['sidebar-modpack-version', serverModpackVersionId.value]),
queryFn: () => client.labrinth.versions_v3.getVersion(serverModpackVersionId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!serverModpackVersionId.value),
})
const serverModpackProjectId = computed(() => serverModpackVersion.value?.project_id ?? null)
const { data: serverModpackProject } = useQuery({
queryKey: computed(() => ['sidebar-modpack-project', serverModpackProjectId.value]),
queryFn: () => client.labrinth.projects_v3.get(serverModpackProjectId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!serverModpackProjectId.value),
})
const serverRequiredContent = computed(() => {
if (!serverModpackProject.value) return null
const primaryFile =
serverModpackVersion.value?.files?.find((f) => f.primary) ??
serverModpackVersion.value?.files?.[0]
return {
name: serverModpackProject.value.name,
versionNumber: serverModpackVersion.value?.version_number ?? '',
icon: serverModpackProject.value.icon_url,
onclickName: () => navigateTo(`/modpack/${serverModpackProject.value.slug}`),
onclickVersion: () =>
navigateTo(
`/modpack/${serverModpackProject.value.slug}/version/${serverModpackVersion.value?.id}`,
),
onclickDownload: primaryFile?.url
? () => navigateTo(primaryFile.url, { external: true })
: undefined,
}
})
const serverRecommendedVersion = computed(() => {
const content = projectV3.value?.minecraft_java_server?.content
if (!content) return null
if (content.kind === 'modpack') {
return serverModpackVersion.value?.game_versions?.[0] ?? null
}
if (content.kind === 'vanilla') {
return content.recommended_game_version ?? null
}
return null
})
const serverSupportedVersions = computed(() => {
const content = projectV3.value?.minecraft_java_server?.content
if (!content) return []
if (content.kind === 'vanilla') {
return content.supported_game_versions?.filter((v) => !!v) ?? []
}
return []
})
const serverModpackLoaders = computed(() => {
if (!serverModpackVersion.value) return []
return serverModpackVersion.value.mrpack_loaders ?? []
})
// Members
const { data: allMembersRaw, error: _membersError } = useQuery({
queryKey: computed(() => ['project', projectId.value, 'members']),
@@ -1711,7 +1860,6 @@ const patchProjectMutation = useMutation({
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async () => {
@@ -1764,6 +1912,57 @@ const patchStatusMutation = useMutation({
},
})
// Mutation for patching V3 project data
const patchProjectV3Mutation = useMutation({
mutationFn: async ({ projectId, data }) => {
await client.labrinth.projects_v3.edit(projectId, data)
return data
},
onMutate: async ({ projectId, data }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
const previousProject = queryClient.getQueryData(['project', 'v3', projectId])
queryClient.setQueryData(['project', 'v3', projectId], (old) => {
if (!old) return old
const merged = { ...old }
for (const [key, value] of Object.entries(data)) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
merged[key] &&
typeof merged[key] === 'object' &&
!Array.isArray(merged[key])
) {
merged[key] = { ...merged[key], ...value }
} else {
merged[key] = value
}
}
return merged
})
return { previousProject, projectId }
},
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
},
onSettled: async () => {
await invalidateProject()
},
})
// Mutation for patching project icon
const patchIconMutation = useMutation({
mutationFn: async ({ projectId, icon }) => {
@@ -1790,7 +1989,6 @@ const patchIconMutation = useMutation({
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async () => {
@@ -2147,7 +2345,30 @@ async function patchProject(resData, quiet = false) {
text: formatMessage(messages.projectUpdatedMessage),
type: 'success',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
resolve(true)
},
onError: () => resolve(false),
onSettled: () => stopLoading(),
},
)
})
}
async function patchProjectV3(resData, quiet = false) {
startLoading()
return new Promise((resolve) => {
patchProjectV3Mutation.mutate(
{ projectId: project.value.id, data: resData },
{
onSuccess: async () => {
if (!quiet) {
addNotification({
title: formatMessage(messages.projectUpdated),
text: formatMessage(messages.projectUpdatedMessage),
type: 'success',
})
}
resolve(true)
},
@@ -2279,7 +2500,8 @@ async function deleteVersion(id) {
}
const navLinks = computed(() => {
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
const routeType = route.params.type || project.value.project_type
const projectUrl = `/${routeType}/${project.value.slug ? project.value.slug : project.value.id}`
return [
{
@@ -2294,13 +2516,15 @@ const navLinks = computed(() => {
{
label: formatMessage(messages.changelogTab),
href: `${projectUrl}/changelog`,
shown: hasVersions.value,
shown: hasVersions.value && projectV3.value?.minecraft_server === undefined,
onHover: loadVersions,
},
{
label: formatMessage(messages.versionsTab),
href: `${projectUrl}/versions`,
shown: hasVersions.value || !!currentMember.value,
shown:
(hasVersions.value || !!currentMember.value) &&
projectV3.value?.minecraft_server === undefined,
subpages: [`${projectUrl}/version/`],
onHover: loadVersions,
},
@@ -2335,6 +2559,7 @@ provideProjectPageContext({
// Mutation functions
patchProject,
patchProjectV3,
patchIcon,
setProcessing,
+18 -13
View File
@@ -32,8 +32,8 @@
:src="
previewImage
? previewImage
: project.gallery?.[editIndex]?.url
? project.gallery[editIndex].url
: filteredGallery[editIndex]?.url
? filteredGallery[editIndex].url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
alt="gallery-preview"
@@ -175,14 +175,14 @@
<ContractIcon v-else aria-hidden="true" />
</button>
<button
v-if="(project?.gallery?.length ?? 0) > 1"
v-if="filteredGallery.length > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="(project?.gallery?.length ?? 0) > 1"
v-if="filteredGallery.length > 1"
class="next circle-button"
@click="nextImage()"
>
@@ -222,7 +222,7 @@
</div>
</template>
</Admonition>
<div v-if="currentMember && project?.gallery?.length" class="card header-buttons">
<div v-if="currentMember && filteredGallery.length" class="card header-buttons">
<FileInput
:max-size="5242880"
:accept="acceptFileTypes"
@@ -243,8 +243,8 @@
@change="handleFiles"
/>
</div>
<div v-if="project?.gallery?.length" class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<div v-if="filteredGallery.length" class="items">
<div v-for="(item, index) in filteredGallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item as GalleryItem, index)">
<img
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
@@ -414,8 +414,13 @@ const previewImage = ref<string | null>(null)
const shouldPreventActions = ref(false)
// Constant for accepted file types
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
const filteredGallery = computed(() =>
project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
// Keyboard navigation for expanded image modal
useEventListener(document, 'keydown', (e) => {
if (expandedGalleryItem.value) {
@@ -435,18 +440,18 @@ useEventListener(document, 'keydown', (e) => {
// Navigation functions
function nextImage() {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= project.value.gallery!.length) {
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
}
function previousImage() {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = project.value.gallery!.length - 1
expandedGalleryIndex.value = filteredGallery.value.length - 1
}
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
}
function expandImage(item: GalleryItem, index: number) {
@@ -509,7 +514,7 @@ async function editGalleryItem() {
shouldPreventActions.value = true
startLoading()
const imageUrl = project.value!.gallery![editIndex.value].url
const imageUrl = filteredGallery.value[editIndex.value].url
const success = await contextEditGalleryItem(
imageUrl,
editTitle.value || undefined,
@@ -529,7 +534,7 @@ async function editGalleryItem() {
async function deleteGalleryImage() {
startLoading()
const imageUrl = project.value!.gallery![deleteIndex.value].url!
const imageUrl = filteredGallery.value[deleteIndex.value].url!
await contextDeleteGalleryItem(imageUrl)
stopLoading()
@@ -7,6 +7,7 @@ import {
ImageIcon,
InfoIcon,
LinkIcon,
ServerIcon,
TagsIcon,
UsersIcon,
VersionIcon,
@@ -36,6 +37,8 @@ const {
const flags = useFeatureFlags()
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const navItems = computed(() => {
const base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
@@ -57,26 +60,36 @@ const navItems = computed(() => {
icon: InfoIcon,
}
: null,
isServerProject.value && {
link: `/${base}/settings/server`,
label: formatMessage(commonProjectSettingsMessages.server),
icon: ServerIcon,
},
{
link: `/${base}/settings/tags`,
label: formatMessage(commonProjectSettingsMessages.tags),
icon: TagsIcon,
},
{
!isServerProject.value && {
link: `/${base}/settings/description`,
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
{
!isServerProject.value && {
link: `/${base}/settings/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),
icon: VersionIcon,
},
{
!isServerProject.value && {
link: `/${base}/settings/license`,
label: formatMessage(commonProjectSettingsMessages.license),
icon: BookTextIcon,
},
isServerProject.value && {
link: `/${base}/settings/description`,
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
{
link: `/${base}/settings/gallery`,
label: formatMessage(commonProjectSettingsMessages.gallery),
@@ -92,13 +105,13 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.members),
icon: UsersIcon,
},
{
!isServerProject.value && {
link: `/${base}/settings/analytics`,
label: formatMessage(commonProjectSettingsMessages.analytics),
icon: ChartIcon,
},
{ type: 'heading', label: 'moderation', shown: showEnvironment },
{
!isServerProject.value && { type: 'heading', label: 'moderation', shown: showEnvironment },
!isServerProject.value && {
link: `/${base}/settings/environment`,
label: formatMessage(commonProjectSettingsMessages.environment),
icon: GlobeIcon,
@@ -125,14 +138,16 @@ watch(route, () => {
<div class="mb-8 flex w-full flex-col gap-4">
<ModerationProjectNags
v-if="
(currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status)
projectV3 &&
((currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status))
"
:project="project"
:versions="versions"
:project-v3="projectV3"
:versions="versions ?? undefined"
:current-member="currentMember"
:collapsed="collapsedChecklist"
:route-name="route.name"
:route-name="route.name as string"
:tags="tags"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
@@ -32,8 +32,8 @@
:src="
previewImage
? previewImage
: project.gallery[editIndex] && project.gallery[editIndex].url
? project.gallery[editIndex].url
: filteredGallery[editIndex] && filteredGallery[editIndex].url
? filteredGallery[editIndex].url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
alt="gallery-preview"
@@ -175,14 +175,14 @@
<ContractIcon v-else aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
v-if="filteredGallery.length > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
v-if="filteredGallery.length > 1"
class="next circle-button"
@click="nextImage()"
>
@@ -215,7 +215,7 @@
/>
</div>
<div class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<div v-for="(item, index) in filteredGallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<img
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
@@ -340,22 +340,27 @@ const editFile = ref(null)
const previewImage = ref(null)
const shouldPreventActions = ref(false)
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
const filteredGallery = computed(() =>
project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
const nextImage = () => {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= project.value.gallery.length) {
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
}
const previousImage = () => {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = project.value.gallery.length - 1
expandedGalleryIndex.value = filteredGallery.value.length - 1
}
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
}
const expandImage = (item, index) => {
@@ -414,7 +419,7 @@ const editGalleryItem = async () => {
shouldPreventActions.value = true
const success = await editGalleryItemMutation(
project.value.gallery[editIndex.value].url,
filteredGallery.value[editIndex.value].url,
editTitle.value || undefined,
editDescription.value || undefined,
editFeatured.value,
@@ -429,7 +434,7 @@ const editGalleryItem = async () => {
}
const deleteGalleryImage = async () => {
await deleteGalleryItemMutation(project.value.gallery[deleteIndex.value].url)
await deleteGalleryItemMutation(filteredGallery.value[deleteIndex.value].url)
}
const handleKeydown = (e) => {
@@ -10,154 +10,236 @@
@proceed="deleteProject"
/>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Project information</span>
</h3>
</div>
<label for="project-icon">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
aria-label="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove icon
</button>
<div class="flex max-w-[600px] flex-col gap-6">
<div class="label">
<h3>
<span class="label__title size-card-header">Project information</span>
</h3>
</div>
</div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<StyledInput id="project-name" v-model="name" :maxlength="2048" :disabled="!hasPermission" />
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">
<span class="hidden sm:inline">https://modrinth.com</span>/{{
$getProjectTypeForUrl(project.project_type, project.loaders)
}}/
</div>
<StyledInput
id="project-slug"
v-model="slug"
:maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
<StyledInput
id="project-summary"
v-model="summary"
multiline
:maxlength="256"
:disabled="!hasPermission"
wrapper-class="summary-input"
/>
<template
v-if="
!flags.newProjectEnvironmentSettings &&
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
<div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<Multiselect
id="project-env-client"
v-model="clientSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
<StyledInput
id="project-name"
v-model="name"
:maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Multiselect
id="project-env-server"
v-model="serverSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
</template>
<div class="adjacent-input">
<label for="project-visibility">
<span class="label__title">Visibility</span>
<div class="label__description">
Public and archived projects are visible in search. Unlisted projects are published, but
not visible in search or on user profiles. Private projects are only accessible by
members of the project.
<p>If approved by the moderators:</p>
<ul class="visibility-info">
<div>
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper !w-full">
<div class="text-input-wrapper__before">
<span class="hidden sm:inline">https://modrinth.com</span>/{{
$getProjectTypeForUrl(project.project_type, project.loaders)
}}/
</div>
<StyledInput
id="project-slug"
v-model="slug"
:maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
</div>
<div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<StyledInput
id="project-summary"
v-model="summary"
multiline
:maxlength="256"
:disabled="!hasPermission"
resize="vertical"
/>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
</div>
<div>
<label for="project-icon">
<span class="label__title"
>Icon <span class="font-normal text-secondary">(optional)</span></span
>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
aria-label="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove icon
</button>
</div>
</div>
</div>
<!-- Server Project Settings -->
<template v-if="isServerProject">
<!-- Banner -->
<div>
<label>
<span class="label__title"
>Banner <span class="font-normal text-secondary">(optional)</span></span
>
</label>
<div class="mt-2">
<label
class="flex cursor-pointer flex-col items-center justify-center rounded-2xl border-dashed border-surface-5 transition-colors"
:class="
!deletedBanner && (bannerPreview || bannerGalleryImage?.url)
? 'border-none'
: 'aspect-[468/60] border-2 bg-surface-2'
"
>
<div
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
class="relative h-full w-full overflow-hidden rounded-2xl"
>
<img
:src="bannerPreview || bannerGalleryImage?.url"
alt="Banner preview"
class="h-full w-full object-cover"
/>
</div>
<ImageIcon v-else aria-hidden="true" class="h-8 w-8 text-secondary" />
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
class="hidden"
:disabled="!hasPermission"
@change="
(e) => {
const input = e.target
if (input.files?.length) {
if (fileIsValid(input.files[0], { maxSize: 524288, alertOnInvalid: true }))
showBannerPreview(Array.from(input.files))
}
}
"
/>
</label>
</div>
<div class="mt-2 flex items-center gap-2">
<FileInput
:max-size="524288"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="iconified-button"
prompt="Upload banner"
:disabled="!hasPermission"
@change="showBannerPreview"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markBannerForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove banner
</button>
</div>
<div class="mt-2 text-secondary">Gif, 468×60px recommended.</div>
</div>
</template>
<template
v-if="
!isServerProject &&
!flags.newProjectEnvironmentSettings &&
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
</label>
<Combobox
v-model="clientSide"
:options="sideTypeOptions"
placeholder="Select one"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Combobox
v-model="serverSide"
:options="sideTypeOptions"
placeholder="Select one"
:disabled="!hasPermission"
/>
</div>
</template>
<div>
<label>
<span class="label__title">Visibility</span>
</label>
<div class="flex flex-col gap-2.5">
<Combobox
v-model="visibility"
:options="visibilityOptions"
placeholder="Select one"
:disabled="!hasPermission"
:max-height="500"
/>
<div>If approved by the moderators:</div>
<ul class="visibility-info m-0">
<li>
<CheckIcon
v-if="visibility === 'approved' || visibility === 'archived'"
@@ -187,31 +269,7 @@
</li>
</ul>
</div>
</label>
<Multiselect
id="project-visibility"
v-model="visibility"
class="small-multiselect"
placeholder="Select one"
:options="tags.approvedStatuses"
:custom-label="(value) => formatProjectStatus(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon aria-hidden="true" />
Save changes
</button>
</div>
</div>
</section>
@@ -235,14 +293,21 @@
Delete project
</button>
</section>
<UnsavedChangesPopup
:original="original"
:modified="modified"
:saving="saving"
@reset="resetChanges"
@save="handleSave"
/>
</div>
</template>
<script setup>
import {
CheckIcon,
ImageIcon,
IssuesIcon,
SaveIcon,
TrashIcon,
TriangleAlertIcon,
UploadIcon,
@@ -251,13 +316,15 @@ import {
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import {
Avatar,
Combobox,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
UnsavedChangesPopup,
} from '@modrinth/ui'
import { formatProjectStatus, formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
import FileInput from '~/components/ui/FileInput.vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
@@ -265,11 +332,13 @@ import { useFeatureFlags } from '~/composables/featureFlags.ts'
const { addNotification } = injectNotificationManager()
const {
projectV2: project,
projectV3,
currentMember,
patchProject,
patchIcon,
invalidate,
} = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const flags = useFeatureFlags()
@@ -290,6 +359,15 @@ const visibility = ref(
: project.value.requested_status,
)
// Server project specific refs
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const bannerPreview = ref(null)
const deletedBanner = ref(false)
const bannerFile = ref(null)
const bannerGalleryImage = computed(() =>
project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME),
)
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
@@ -311,9 +389,37 @@ const summaryWarning = computed(() => {
return null
})
const sideTypes = ['required', 'optional', 'unsupported']
const sideTypeOptions = [
{ value: 'required', label: 'Required' },
{ value: 'optional', label: 'Optional' },
{ value: 'unsupported', label: 'Unsupported' },
]
const patchData = computed(() => {
const visibilityOptions = computed(() =>
tags.value.approvedStatuses.map((status) => {
const subLabel = () => {
switch (status) {
case 'approved':
return 'Visible via URL, on your profile, and in search.'
case 'archived':
return 'Visible via URL, on your profile, and in search, but marked as archived.'
case 'unlisted':
return 'Visible via URL only. Not shown on your profile or in search.'
case 'private':
return 'Not publicly visible. Only accessible to project members.'
default:
return ''
}
}
return {
value: status,
label: formatProjectStatus(status),
subLabel: subLabel(),
}
}),
)
const basePatchData = computed(() => {
const data = {}
if (name.value !== project.value.title) {
@@ -342,9 +448,52 @@ const patchData = computed(() => {
return data
})
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
})
const saving = ref(false)
const original = computed(() => ({
name: project.value.title,
slug: project.value.slug,
summary: project.value.description,
clientSide: project.value.client_side,
serverSide: project.value.server_side,
visibility: tags.value.approvedStatuses.includes(project.value.status)
? project.value.status
: project.value.requested_status,
icon: null,
deletedIcon: false,
bannerFile: null,
deletedBanner: false,
}))
const modified = computed(() => ({
name: name.value,
slug: slug.value,
summary: summary.value,
clientSide: clientSide.value,
serverSide: serverSide.value,
visibility: visibility.value,
icon: icon.value,
deletedIcon: deletedIcon.value,
bannerFile: bannerFile.value,
deletedBanner: deletedBanner.value,
}))
function resetChanges() {
name.value = project.value.title
slug.value = project.value.slug
summary.value = project.value.description
clientSide.value = project.value.client_side
serverSide.value = project.value.server_side
visibility.value = tags.value.approvedStatuses.includes(project.value.status)
? project.value.status
: project.value.requested_status
icon.value = null
previewImage.value = null
deletedIcon.value = false
bannerFile.value = null
bannerPreview.value = null
deletedBanner.value = false
}
const hasModifiedVisibility = () => {
const originalVisibility = tags.value.approvedStatuses.includes(project.value.status)
@@ -354,17 +503,33 @@ const hasModifiedVisibility = () => {
return originalVisibility !== visibility.value
}
const saveChanges = async () => {
if (hasChanges.value) {
await patchProject(patchData.value)
}
async function handleSave() {
saving.value = true
try {
const hasV2Changes = Object.keys(basePatchData.value).length > 0
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await patchIcon(icon.value)
icon.value = null
if (hasV2Changes) {
await patchProject(basePatchData.value)
}
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await patchIcon(icon.value)
icon.value = null
}
if (deletedBanner.value) {
await deleteBanner()
deletedBanner.value = false
} else if (bannerFile.value) {
await uploadBanner()
bannerFile.value = null
bannerPreview.value = null
}
} finally {
saving.value = false
}
}
@@ -378,6 +543,83 @@ const showPreviewImage = (files) => {
}
}
const showBannerPreview = (files) => {
const file = files[0]
if (file) {
bannerFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
bannerPreview.value = e.target.result
}
reader.readAsDataURL(file)
deletedBanner.value = false
}
}
const markBannerForDeletion = () => {
bannerPreview.value = null
bannerFile.value = null
deletedBanner.value = true
}
const uploadBanner = async () => {
if (!bannerFile.value) return
try {
// First, delete existing banner image if there is one
const existingBanner = project.value.gallery?.find(
(img) => img.title === MC_SERVER_BANNER_NAME,
)
if (existingBanner) {
await labrinth.projects_v2.deleteGalleryImage(project.value.id, existingBanner.url)
}
// Upload new banner as gallery image with special title
const ext = bannerFile.value.type.split('/').pop() ?? 'png'
await labrinth.projects_v2.createGalleryImage(project.value.id, bannerFile.value, {
ext,
featured: false,
title: MC_SERVER_BANNER_NAME,
})
await invalidate()
addNotification({
title: 'Banner updated',
text: 'Your project banner has been updated.',
type: 'success',
})
} catch (err) {
addNotification({
title: 'Failed to update banner',
text: err.data?.description ?? String(err),
type: 'error',
})
}
}
const deleteBanner = async () => {
try {
const bannerImage = project.value.gallery?.find(
(img) => img.title === MC_SERVER_BANNER_NAME,
)
if (bannerImage) {
await labrinth.projects_v2.deleteGalleryImage(project.value.id, bannerImage.url)
await invalidate()
addNotification({
title: 'Banner removed',
text: 'Your project banner has been removed.',
type: 'success',
})
}
} catch (err) {
addNotification({
title: 'Failed to remove banner',
text: err.data?.description ?? String(err),
type: 'error',
})
}
}
const deleteProject = async () => {
await useBaseFetch(`project/${project.value.id}`, {
method: 'DELETE',
@@ -409,10 +651,14 @@ const deleteIcon = async () => {
})
}
</script>
<style lang="scss" scoped>
.visibility-info {
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
li {
display: flex;
@@ -435,15 +681,6 @@ svg {
}
}
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
.small-multiselect {
max-width: 15rem;
}
.button-group {
justify-content: flex-start;
}
@@ -1,6 +1,84 @@
<template>
<div>
<section class="universal-card">
<!-- Server Project Links -->
<section v-if="isServerProject" class="universal-card">
<h2>External links</h2>
<div class="adjacent-input">
<label id="server-website" title="Your server's website.">
<span class="label__title">Website</span>
<span class="label__description">Your server's official website.</span>
</label>
<input
id="server-website"
v-model="siteUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label id="server-store" title="Your server's store page.">
<span class="label__title">Store</span>
<span class="label__description">A link to your server's store or shop.</span>
</label>
<input
id="server-store"
v-model="storeUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="server-wiki"
title="A page containing information, documentation, and help for the server."
>
<span class="label__title">Wiki page</span>
<span class="label__description"
>A page containing information, documentation, and help for the server.</span
>
</label>
<input
id="server-wiki"
v-model="serverWikiUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label id="server-discord" title="An invitation link to your Discord server.">
<span class="label__title">Discord</span>
<span class="label__description">An invitation link to your Discord server.</span>
</label>
<input
id="server-discord"
v-model="serverDiscordUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasServerChanges"
@click="saveServerChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
<!-- Standard Project Links -->
<section v-if="!isServerProject" class="universal-card">
<h2>External links</h2>
<div class="adjacent-input">
<label
@@ -174,17 +252,51 @@
<script setup>
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
import { DropdownSelect, injectProjectPageContext, StyledInput } from '@modrinth/ui'
import {
DropdownSelect,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
} from '@modrinth/ui'
const tags = useGeneratedState()
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const {
projectV2: project,
projectV3,
currentMember,
patchProject,
invalidate,
} = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const issuesUrl = ref(project.value.issues_url)
const sourceUrl = ref(project.value.source_url)
const wikiUrl = ref(project.value.wiki_url)
const discordUrl = ref(project.value.discord_url)
// Server project links
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const siteUrl = ref(projectV3.value?.link_urls?.site?.url ?? '')
const storeUrl = ref(projectV3.value?.link_urls?.store?.url ?? '')
const serverWikiUrl = ref(projectV3.value?.link_urls?.wiki?.url ?? '')
const serverDiscordUrl = ref(projectV3.value?.link_urls?.discord?.url ?? '')
watch(
projectV3,
(newVal) => {
if (newVal) {
siteUrl.value = newVal.link_urls?.site?.url ?? ''
storeUrl.value = newVal.link_urls?.store?.url ?? ''
serverWikiUrl.value = newVal.link_urls?.wiki?.url ?? ''
serverDiscordUrl.value = newVal.link_urls?.discord?.url ?? ''
}
},
{ immediate: true },
)
const isIssuesUrlCommon = computed(() => {
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues)
@@ -281,6 +393,56 @@ const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0
})
// Server project links
const serverPatchData = computed(() => {
const data = {}
const originalSite = projectV3.value?.link_urls?.site?.url ?? ''
const originalStore = projectV3.value?.link_urls?.store?.url ?? ''
const originalWiki = projectV3.value?.link_urls?.wiki?.url ?? ''
const originalDiscord = projectV3.value?.link_urls?.discord?.url ?? ''
if (checkDifference(siteUrl.value, originalSite)) {
data.site = siteUrl.value === '' ? null : siteUrl.value?.trim()
}
if (checkDifference(storeUrl.value, originalStore)) {
data.store = storeUrl.value === '' ? null : storeUrl.value?.trim()
}
if (checkDifference(serverWikiUrl.value, originalWiki)) {
data.wiki = serverWikiUrl.value === '' ? null : serverWikiUrl.value?.trim()
}
if (checkDifference(serverDiscordUrl.value, originalDiscord)) {
data.discord = serverDiscordUrl.value === '' ? null : serverDiscordUrl.value?.trim()
}
return data
})
const hasServerChanges = computed(() => {
return Object.keys(serverPatchData.value).length > 0
})
async function saveServerChanges() {
const linkUpdates = serverPatchData.value
if (Object.keys(linkUpdates).length === 0) return
try {
await labrinth.projects_v3.edit(project.value.id, {
link_urls: linkUpdates,
})
await invalidate()
addNotification({
title: 'Links updated',
text: 'Your server links have been updated.',
type: 'success',
})
} catch (err) {
addNotification({
title: 'Failed to update links',
text: err.data?.description ?? String(err),
type: 'error',
})
}
}
async function saveChanges() {
if (patchData.value && (await patchProject(patchData.value))) {
donationLinks.value = JSON.parse(JSON.stringify(project.value.donation_urls))
@@ -321,13 +483,7 @@ function updateDonationLinks() {
}
function checkDifference(newLink, existingLink) {
if (newLink === '' && existingLink !== null) {
return true
}
if (!newLink && !existingLink) {
return false
}
return newLink !== existingLink
return newLink != existingLink
}
</script>
<style lang="scss" scoped>
@@ -0,0 +1,408 @@
<template>
<div>
<section class="universal-card">
<div class="flex flex-col gap-6">
<div class="text-2xl font-semibold text-contrast">Server details</div>
<!-- Country -->
<div class="max-w-[600px]">
<label for="server-country">
<span class="label__title">Country</span>
</label>
<Combobox
id="server-country"
v-model="country"
:options="countryOptions"
searchable
placeholder="Select country"
:disabled="!hasPermission"
/>
</div>
<!-- Language -->
<div class="max-w-[600px]">
<label for="server-language">
<span class="label__title">Language</span>
</label>
<Multiselect
id="server-language"
v-model="languages"
:options="languageOptions.map((l) => l.value)"
:custom-label="(code) => languageOptions.find((l) => l.value === code)?.label ?? code"
:multiple="true"
:searchable="true"
:show-labels="false"
:close-on-select="false"
placeholder="Select languages"
:disabled="!hasPermission"
/>
</div>
<!-- Java Address -->
<div class="max-w-[600px]">
<label for="java-address">
<span class="label__title !text-contrast">Java address</span>
</label>
<div class="mt-2 flex items-center gap-2" @focusout="pingJavaServer">
<StyledInput
id="java-address"
v-model="javaAddress"
placeholder="Enter address"
:disabled="!hasPermission"
wrapper-class="flex-grow"
autocomplete="off"
/>
<StyledInput
v-model="javaPort"
type="number"
:min="1"
:max="65535"
:disabled="!hasPermission"
wrapper-class="w-24"
input-class="text-center"
autocomplete="off"
/>
</div>
<div
v-if="javaPingResult !== null"
class="mt-2 flex items-center gap-1.5"
:class="javaPingResult.online ? 'text-green' : 'text-orange'"
>
<CheckIcon v-if="javaPingResult.online" class="h-4 w-4" />
<TriangleAlertIcon v-else class="h-4 w-4" />
{{
javaPingResult.online
? `Server is online! ${javaPingResult.latency ? `Latency: ${javaPingResult.latency}ms` : ``}`
: 'Cannot ping server'
}}
</div>
</div>
<!-- Bedrock Address -->
<div class="max-w-[600px]">
<label for="bedrock-address">
<span class="label__title !text-contrast">Bedrock address</span>
</label>
<div class="mt-2 flex items-center gap-2">
<StyledInput
id="bedrock-address"
v-model="bedrockAddress"
placeholder="Enter address"
:disabled="!hasPermission"
wrapper-class="flex-grow"
autocomplete="off"
/>
<StyledInput
v-model="bedrockPort"
type="number"
:min="1"
:max="65535"
:disabled="!hasPermission"
wrapper-class="w-24"
input-class="text-center"
autocomplete="off"
/>
</div>
</div>
<CompatibilityCard />
</div>
</section>
<UnsavedChangesPopup
:original="original"
:modified="modified"
:saving="saving"
@reset="resetChanges"
@save="handleSave"
/>
</div>
</template>
<script setup>
import { CheckIcon, TriangleAlertIcon } from '@modrinth/assets'
import {
Combobox,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
UnsavedChangesPopup,
} from '@modrinth/ui'
import { Multiselect } from 'vue-multiselect'
import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue'
const { addNotification } = injectNotificationManager()
const { projectV2: project, projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
const javaAddress = ref('')
const javaPort = ref(25565)
const bedrockAddress = ref('')
const bedrockPort = ref(19132)
const country = ref('')
const languages = ref([])
function initFromProjectV3(v3) {
if (!v3) return
javaAddress.value = v3.minecraft_java_server?.address ?? ''
javaPort.value = v3.minecraft_java_server?.port ?? 25565
bedrockAddress.value = v3.minecraft_bedrock_server?.address ?? ''
bedrockPort.value = v3.minecraft_bedrock_server?.port ?? 19132
country.value = v3.minecraft_server?.country ?? ''
languages.value = v3.minecraft_server?.languages ?? []
}
// initialize projectV3 values once
if (projectV3.value) {
initFromProjectV3(projectV3.value)
} else {
const stop = watch(
() => projectV3.value,
(v3) => {
if (!v3) return
initFromProjectV3(v3)
stop()
},
)
}
const javaPingLoading = ref(false)
const javaPingResult = ref(null)
const pingJavaServer = async () => {
const address = javaAddress.value?.trim()
if (!address) {
javaPingResult.value = null
return
}
javaPingLoading.value = true
javaPingResult.value = null
const port = javaPort.value || 25565
const query = port !== 25565 ? `${address}:${port}` : address
try {
// TODO replace with api-client labrinth server ping route
} catch {
javaPingResult.value = { online: false, latency: null }
} finally {
javaPingLoading.value = false
}
}
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
})
const countryOptions = [
{ value: 'US', label: 'United States' },
{ value: 'CA', label: 'Canada' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'DE', label: 'Germany' },
{ value: 'FR', label: 'France' },
{ value: 'NL', label: 'Netherlands' },
{ value: 'FI', label: 'Finland' },
{ value: 'SE', label: 'Sweden' },
{ value: 'NO', label: 'Norway' },
{ value: 'DK', label: 'Denmark' },
{ value: 'PL', label: 'Poland' },
{ value: 'CZ', label: 'Czech Republic' },
{ value: 'RO', label: 'Romania' },
{ value: 'CH', label: 'Switzerland' },
{ value: 'AT', label: 'Austria' },
{ value: 'BE', label: 'Belgium' },
{ value: 'IE', label: 'Ireland' },
{ value: 'ES', label: 'Spain' },
{ value: 'IT', label: 'Italy' },
{ value: 'PT', label: 'Portugal' },
{ value: 'RU', label: 'Russia' },
{ value: 'UA', label: 'Ukraine' },
{ value: 'LT', label: 'Lithuania' },
{ value: 'LV', label: 'Latvia' },
{ value: 'EE', label: 'Estonia' },
{ value: 'BG', label: 'Bulgaria' },
{ value: 'HR', label: 'Croatia' },
{ value: 'HU', label: 'Hungary' },
{ value: 'SK', label: 'Slovakia' },
{ value: 'RS', label: 'Serbia' },
{ value: 'GR', label: 'Greece' },
{ value: 'TR', label: 'Turkey' },
{ value: 'IL', label: 'Israel' },
{ value: 'AE', label: 'United Arab Emirates' },
{ value: 'SA', label: 'Saudi Arabia' },
{ value: 'IN', label: 'India' },
{ value: 'SG', label: 'Singapore' },
{ value: 'JP', label: 'Japan' },
{ value: 'KR', label: 'South Korea' },
{ value: 'CN', label: 'China' },
{ value: 'HK', label: 'Hong Kong' },
{ value: 'TW', label: 'Taiwan' },
{ value: 'AU', label: 'Australia' },
{ value: 'NZ', label: 'New Zealand' },
{ value: 'BR', label: 'Brazil' },
{ value: 'AR', label: 'Argentina' },
{ value: 'CL', label: 'Chile' },
{ value: 'CO', label: 'Colombia' },
{ value: 'MX', label: 'Mexico' },
{ value: 'ZA', label: 'South Africa' },
{ value: 'NG', label: 'Nigeria' },
{ value: 'KE', label: 'Kenya' },
{ value: 'EG', label: 'Egypt' },
{ value: 'MY', label: 'Malaysia' },
{ value: 'TH', label: 'Thailand' },
{ value: 'VN', label: 'Vietnam' },
{ value: 'PH', label: 'Philippines' },
{ value: 'ID', label: 'Indonesia' },
{ value: 'PK', label: 'Pakistan' },
{ value: 'BD', label: 'Bangladesh' },
]
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'it', label: 'Italian' },
{ value: 'nl', label: 'Dutch' },
{ value: 'ru', label: 'Russian' },
{ value: 'uk', label: 'Ukrainian' },
{ value: 'pl', label: 'Polish' },
{ value: 'cs', label: 'Czech' },
{ value: 'sk', label: 'Slovak' },
{ value: 'hu', label: 'Hungarian' },
{ value: 'ro', label: 'Romanian' },
{ value: 'bg', label: 'Bulgarian' },
{ value: 'hr', label: 'Croatian' },
{ value: 'sr', label: 'Serbian' },
{ value: 'el', label: 'Greek' },
{ value: 'tr', label: 'Turkish' },
{ value: 'ar', label: 'Arabic' },
{ value: 'he', label: 'Hebrew' },
{ value: 'hi', label: 'Hindi' },
{ value: 'bn', label: 'Bengali' },
{ value: 'ur', label: 'Urdu' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'th', label: 'Thai' },
{ value: 'vi', label: 'Vietnamese' },
{ value: 'id', label: 'Indonesian' },
{ value: 'ms', label: 'Malay' },
{ value: 'tl', label: 'Filipino' },
{ value: 'sv', label: 'Swedish' },
{ value: 'no', label: 'Norwegian' },
{ value: 'da', label: 'Danish' },
{ value: 'fi', label: 'Finnish' },
{ value: 'lt', label: 'Lithuanian' },
{ value: 'lv', label: 'Latvian' },
{ value: 'et', label: 'Estonian' },
]
const javaServerPatchData = computed(() => {
const origJava = projectV3.value?.minecraft_java_server
const addressChanged =
javaAddress.value !== (origJava?.address ?? '') ||
javaPort.value !== (origJava?.port ?? 25565)
if (addressChanged) {
return {
address: javaAddress.value.trim(),
port: javaPort.value,
}
}
return {}
})
const bedrockServerPatchData = computed(() => {
const origBedrock = projectV3.value?.minecraft_bedrock_server
if (
bedrockAddress.value !== (origBedrock?.address ?? '') ||
bedrockPort.value !== (origBedrock?.port ?? 19132)
) {
return {
address: bedrockAddress.value.trim(),
port: bedrockPort.value,
}
}
return {}
})
const serverPatchData = computed(() => {
const origServer = projectV3.value?.minecraft_server
const countryChanged = country.value && country.value !== origServer?.country
const languagesChanged =
JSON.stringify([...languages.value].sort()) !==
JSON.stringify([...(origServer?.languages ?? [])].sort())
if (countryChanged || languagesChanged) {
return {
...origServer,
...(countryChanged ? { country: country.value } : {}),
...(languagesChanged ? { languages: languages.value } : {}),
}
}
return {}
})
const v3PatchData = computed(() => {
const data = {}
if (Object.keys(serverPatchData.value).length > 0) {
data.minecraft_server = serverPatchData.value
}
if (Object.keys(javaServerPatchData.value).length > 0) {
data.minecraft_java_server = javaServerPatchData.value
}
if (Object.keys(bedrockServerPatchData.value).length > 0) {
data.minecraft_bedrock_server = bedrockServerPatchData.value
}
return data
})
const saving = ref(false)
const original = computed(() => ({
javaAddress: projectV3.value?.minecraft_java_server?.address ?? '',
javaPort: projectV3.value?.minecraft_java_server?.port ?? 25565,
bedrockAddress: projectV3.value?.minecraft_bedrock_server?.address ?? '',
bedrockPort: projectV3.value?.minecraft_bedrock_server?.port ?? 19132,
country: projectV3.value?.minecraft_server?.country ?? '',
languages: projectV3.value?.minecraft_server?.languages ?? [],
}))
const modified = computed(() => ({
javaAddress: javaAddress.value,
javaPort: javaPort.value,
bedrockAddress: bedrockAddress.value,
bedrockPort: bedrockPort.value,
country: country.value,
languages: languages.value,
}))
function resetChanges() {
javaAddress.value = projectV3.value?.minecraft_java_server?.address ?? ''
javaPort.value = projectV3.value?.minecraft_java_server?.port ?? 25565
bedrockAddress.value = projectV3.value?.minecraft_bedrock_server?.address ?? ''
bedrockPort.value = projectV3.value?.minecraft_bedrock_server?.port ?? 19132
country.value = projectV3.value?.minecraft_server?.country ?? ''
languages.value = projectV3.value?.minecraft_server?.languages ?? []
}
async function handleSave() {
saving.value = true
try {
const hasV3Changes = Object.keys(v3PatchData.value).length > 0
if (hasV3Changes) {
await patchProjectV3(v3PatchData.value)
}
} finally {
saving.value = false
}
}
</script>
@@ -31,7 +31,10 @@
that apply.
</p>
<p v-if="project.versions.length === 0" class="known-errors">
<p
v-if="project.versions.length === 0 && projectV3?.minecraft_server == null"
class="known-errors"
>
Please upload a version first in order to select tags!
</p>
<template v-else>
@@ -156,24 +159,28 @@ interface Category {
const tags = useGeneratedState()
const { formatMessage, locale } = useVIntl()
const { projectV2: project, patchProject } = injectProjectPageContext()
const { projectV2: project, projectV3, patchProject } = injectProjectPageContext()
const formatCategoryName = (categoryName: string) => {
return formatCategory(formatMessage, categoryName)
}
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const matchesProjectType = (x: Category) =>
x.project_type === project.value.actualProjectType ||
(x.project_type === 'minecraft_java_server' && isServerProject.value)
const { saved, current, saving, reset, save } = useSavable(
() => ({
selectedTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
(x: Category) =>
x.project_type === project.value.actualProjectType &&
matchesProjectType(x) &&
(project.value.categories.includes(x.name) ||
project.value.additional_categories.includes(x.name)),
) as Category[],
featuredTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
(x: Category) =>
x.project_type === project.value.actualProjectType &&
project.value.categories.includes(x.name),
(x: Category) => matchesProjectType(x) && project.value.categories.includes(x.name),
) as Category[],
}),
async () => {
@@ -217,7 +224,7 @@ const { saved, current, saving, reset, save } = useSavable(
const categoryLists = computed(() => {
const lists: Record<string, Category[]> = {}
sortedCategories(tags.value, formatCategoryName, locale.value).forEach((x: Category) => {
if (x.project_type === project.value.actualProjectType) {
if (matchesProjectType(x)) {
const header = x.header
if (!lists[header]) {
lists[header] = []
@@ -230,7 +237,13 @@ const categoryLists = computed(() => {
const tooManyTagsWarning = computed(() => {
const tagCount = current.value.selectedTags.length
if (tagCount > 8) {
if (projectV3?.value?.minecraft_server != null) {
if (tagCount > 18) {
return `You've selected ${tagCount} tags. Please reduce to 18 or fewer to keep your server focused and easier to discover.`
} else if (tagCount > 12) {
return `You've selected ${tagCount} tags. Consider reducing to 12 or fewer to keep your server focused and easier to discover.`
}
} else if (tagCount > 8) {
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`
}
return null
@@ -208,7 +208,7 @@
</ProjectPageVersions>
<template v-if="!versions?.length">
<div class="grid place-content-center py-10">
<div class="grid place-items-center py-10">
<svg
width="250"
height="200"
+1 -1
View File
@@ -20,7 +20,7 @@
link: '/dashboard/affiliate-links',
label: formatMessage(commonMessages.affiliateLinksButton),
icon: AffiliateIcon,
shown: isAffiliate,
shown: !!isAffiliate,
},
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon, matchNested: true },
]"
+12 -1
View File
@@ -159,7 +159,10 @@
<div class="header__row">
<h2 class="header__title text-2xl">Projects</h2>
<div class="input-group">
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
<button
class="iconified-button brand-button"
@click="$refs.modal_creation.show($event, { showTypeSelector: true })"
>
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
@@ -454,6 +457,14 @@ async function bulkEditLinks() {
await initUserProjects()
if (user.value?.projects) {
projects.value = updateSort(user.value.projects, 'Name', false)
// minecraft_java_server type determined from component on projectV3
projects.value = projects.value.map((project) => {
const projectV3 = user.value?.projectsV3?.find((p) => p.id === project.id)
if (projectV3?.minecraft_server != null)
return { ...project, project_type: 'minecraft_java_server' }
return project
})
user.value?.projectsV3?.forEach((project) => {
if (
project.side_types_migration_review_status === 'pending' &&
+272 -124
View File
@@ -24,6 +24,7 @@ import {
DropdownSelect,
injectModrinthClient,
injectNotificationManager,
OpenInAppModal,
Pagination,
ProjectCard,
ProjectCardList,
@@ -33,11 +34,12 @@ import {
StyledInput,
Toggle,
useSearch,
useServerSearch,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { useThrottleFn, useTimeoutFn } from '@vueuse/core'
import { templateRef, useThrottleFn, useTimeoutFn } from '@vueuse/core'
import { computed, type Reactive, watch } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
@@ -84,6 +86,10 @@ const currentType = computed(() =>
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
)
watch(currentType, (newType) => {
console.log('currentType changed:', newType)
})
const projectType = computed(() => tags.value.projectTypes.find((x) => x.id === currentType.value))
const projectTypes = computed(() => (projectType.value ? [projectType.value.id] : []))
@@ -99,6 +105,7 @@ const resultsDisplayMode = computed<DisplayMode>(() =>
const server = ref<Reactive<ModrinthServer>>()
const serverHideInstalled = ref(false)
const eraseDataOnInstall = ref(false)
const serverInAppModal = templateRef('openInAppModal')
const PERSISTENT_QUERY_PARAMS = ['sid', 'shi']
@@ -336,6 +343,28 @@ async function serverInstall(project: InstallableSearchResult) {
}
const noLoad = ref(false)
const {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes,
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags, query, maxResults, currentPage })
const effectiveSortType = computed({
get: () => (currentType.value === 'server' ? serverCurrentSortType.value : currentSortType.value),
set: (v: SortType) => {
if (currentType.value === 'server') serverCurrentSortType.value = v
else currentSortType.value = v
},
})
const effectiveSortTypes = computed(() =>
currentType.value === 'server' ? serverSortTypes : [...sortTypes],
)
const {
data: rawResults,
refresh: refreshSearch,
@@ -343,7 +372,12 @@ const {
} = useLazyFetch(
() => {
const config = useRuntimeConfig()
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (currentType.value === 'server') {
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
return `${base}search${serverRequestParams.value}`
}
return `${base}search${requestParams.value}`
},
@@ -395,7 +429,7 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
const params = {
...persistentParams,
...createPageParams(),
...(currentType.value === 'server' ? createServerPageParams() : createPageParams()),
}
router.replace({ path: route.path, query: params })
@@ -406,6 +440,12 @@ watch([currentFilters], () => {
updateSearchResults(1, false)
})
watch([serverCurrentFilters, serverCurrentSortType], () => {
if (currentType.value === 'server') {
updateSearchResults(1, false)
}
})
const throttledSearch = useThrottleFn(() => updateSearchResults(), 500, true)
function cycleSearchDisplayMode() {
@@ -444,6 +484,45 @@ useSeoMeta({
ogTitle,
ogDescription: description,
})
const serverHits = computed(
() =>
((rawResults.value as unknown as Labrinth.Search.v3.SearchResults)
?.hits as Labrinth.Search.v3.ResultSearchProject[]) ?? [],
)
const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) => {
const content = hit.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick:
project_id !== hit.project_id
? () => {
navigateTo(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === hit.project_id,
}
}
return undefined
}
function handleServerProjectPlay(project: Labrinth.Search.v3.ResultSearchProject) {
serverInAppModal.value?.show({
serverProject: {
name: project.name,
slug: project.slug ?? '',
numPlayers: project.minecraft_java_server?.ping?.data?.players_online,
icon: project.icon_url ?? '',
statusOnline: !!project.minecraft_java_server?.ping?.data,
region: project.minecraft_server?.country,
},
})
}
</script>
<template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
@@ -547,41 +626,66 @@ useSeoMeta({
@update:model-value="updateSearchResults()"
/>
</div>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
<template v-if="currentType === 'server'">
<SearchSidebarFilter
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="serverCurrentFilters"
v-model:toggled-groups="serverToggledGroups"
:provided-filters="serverFilters"
:filter-type="filterType"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!['server_category_minecraft_server_meta', 'server_category_minecraft_server_community', 'server_game_version'].includes(filterType.id)"
>
<template #header>
<h3 class="m-0 text-lg">{{ filterType.formatted_name }}</h3>
</template>
</SearchSidebarFilter>
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</template>
</div>
</aside>
<section class="normal-page__content">
@@ -601,10 +705,10 @@ useSeoMeta({
<div class="flex flex-wrap items-center gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
v-model="effectiveSortType"
class="!w-auto flex-grow md:flex-grow-0"
name="Sort by"
:options="[...sortTypes]"
:options="effectiveSortTypes"
:display-name="(option?: SortType) => option?.display"
@change="updateSearchResults()"
>
@@ -650,6 +754,14 @@ useSeoMeta({
/>
</div>
<SearchFilterControl
v-if="currentType === 'server'"
v-model:selected-filters="serverCurrentFilters"
:filters="serverFilterTypes"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="serverFilters"
@@ -657,7 +769,14 @@ useSeoMeta({
:provided-message="messages.providedByServer"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<div
v-else-if="
currentType === 'server'
? serverHits.length === 0
: results && results.hits && results.hits.length === 0
"
class="no-results"
>
<p>No results found for your query!</p>
</div>
<div v-else class="search-results-container">
@@ -667,90 +786,117 @@ useSeoMeta({
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
>
<ProjectCard
v-for="result in results?.hits"
:key="result.project_id"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
:author="{ name: result.author, link: `/user/${result.author}` }"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
:downloads="result.downloads"
:summary="result.description"
:tags="result.display_categories"
:all-tags="result.categories"
:deprioritized-tags="deprioritizedTags"
:exclude-loaders="excludeLoaders"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side,
serverSide: result.server_side,
}
: undefined
"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
<DownloadIcon />
Download
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<HeartIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<BookmarkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
<template v-if="currentType === 'server'">
<ProjectCard
v-for="project in serverHits"
:key="`server-card-${project.project_id}`"
:title="project.name"
:icon-url="project.icon_url || undefined"
:summary="project.summary"
:tags="project.categories"
:link="`/server/${project.slug}`"
:server-online-players="
project.minecraft_java_server?.ping?.data?.players_online ?? 0
"
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
:server-region-code="project.minecraft_server?.country"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:server-modpack-content="getServerModpackContent(project)"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
:max-tags="2"
is-server-project
exclude-loaders
>
</ProjectCard>
</template>
<template v-else>
<ProjectCard
v-for="result in results?.hits"
:key="result.project_id"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
:author="{ name: result.author, link: `/user/${result.author}` }"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
:downloads="result.downloads"
:summary="result.description"
:tags="result.display_categories"
:all-tags="result.categories"
:deprioritized-tags="deprioritizedTags"
:exclude-loaders="excludeLoaders"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
}
: undefined
"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
<DownloadIcon />
Download
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<HeartIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<BookmarkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</template>
<template v-else-if="server">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
"
disabled
>
<CheckIcon />
Installed
</button>
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
Installing...
</button>
<button v-else @click="serverInstall(result as InstallableSearchResult)">
<DownloadIcon />
Install
</button>
</ButtonStyled>
</template>
</template>
<template v-else-if="server">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
"
disabled
>
<CheckIcon />
Installed
</button>
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
Installing...
</button>
<button v-else @click="serverInstall(result as InstallableSearchResult)">
<DownloadIcon />
Install
</button>
</ButtonStyled>
</template>
</template>
</ProjectCard>
</ProjectCard>
</template>
</ProjectCardList>
</div>
<div class="pagination-after">
@@ -763,6 +909,8 @@ useSeoMeta({
</div>
</div>
</section>
<OpenInAppModal ref="openInAppModal" />
</template>
<style lang="scss" scoped>
.normal-page__content {
+6 -3
View File
@@ -221,6 +221,7 @@ const filterTypes: ComboboxOption<string>[] = [
{ value: 'Data Packs', label: 'Data Packs' },
{ value: 'Plugins', label: 'Plugins' },
{ value: 'Shaders', label: 'Shaders' },
{ value: 'Servers', label: 'Servers' },
]
const currentSortType = ref('Oldest')
@@ -282,15 +283,17 @@ const typeFiltered = computed(() => {
'Data Packs': 'datapack',
Plugins: 'plugin',
Shaders: 'shader',
Servers: 'minecraft_java_server',
}
const projectType = filterMap[currentFilterType.value]
if (!projectType) return baseFiltered.value
return baseFiltered.value.filter(
(queueItem) =>
queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType,
(queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType) ||
(projectType === 'minecraft_java_server' &&
queueItem.project.project_types.includes('minecraft_java_server')),
)
})
@@ -218,7 +218,7 @@ const onDeleteOrganization = useClientTry(async () => {
multiline
:maxlength="256"
:disabled="!hasPermission"
wrapper-class="summary-input"
resize="vertical"
/>
</div>
<div class="universal-card">
@@ -245,10 +245,3 @@ const onDeleteOrganization = useClientTry(async () => {
/>
</div>
</template>
<style scoped lang="scss">
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
</style>
@@ -159,7 +159,10 @@
<div class="header__row">
<h2 class="header__title text-2xl">Projects</h2>
<div class="input-group">
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
<button
class="iconified-button brand-button"
@click="$refs.modal_creation.show($event, { showTypeSelector: true })"
>
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
+1
View File
@@ -48,6 +48,7 @@ export default defineNuxtPlugin({
modpack: 'list',
shader: 'gallery',
datapack: 'list',
server: 'list',
user: 'list',
collection: 'list',
},
@@ -0,0 +1,425 @@
import type { Labrinth, UploadProgress } from '@modrinth/api-client'
import { ArrowLeftRightIcon, LeftArrowIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
createContext,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
type MultiStageModal,
type StageConfigInput,
} from '@modrinth/ui'
import JSZip from 'jszip'
import type { Ref, ShallowRef } from 'vue'
import { markRaw, toRaw } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import SelectCompatibilityType from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectCompatibilityType.vue'
import SelectPublishedModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue'
import SelectVanillaVersions from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectVanillaVersions.vue'
import UploadCustomModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/UploadCustomModpack.vue'
export type CompatibilityType = 'vanilla' | 'published-modpack' | 'custom-modpack'
export interface ServerCompatibilityContextValue {
// Stage management
stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[]
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
isSubmitting: Ref<boolean>
isUploading: Ref<boolean>
uploadProgress: Ref<UploadProgress>
// State
compatibilityType: Ref<CompatibilityType | null>
selectedProjectId: Ref<string>
selectedVersionId: Ref<string>
supportedGameVersions: Ref<string[]>
recommendedGameVersion: Ref<string | null>
customModpackFile: Ref<File | null>
hasLicensePermission: Ref<boolean>
isEditingExistingCompatibility: Ref<boolean>
isSwitchingCompatibilityType: Ref<boolean>
// Actions
resetContext: () => void
handleSave: () => Promise<void>
}
export const [injectServerCompatibilityContext, provideServerCompatibilityContext] =
createContext<ServerCompatibilityContextValue>('ServerCompatibilityModal')
export function createServerCompatibilityContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
): ServerCompatibilityContextValue {
const { projectV3, patchProjectV3 } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const isSubmitting = ref(false)
const isUploading = ref(false)
const uploadProgress = ref<UploadProgress>({ loaded: 0, total: 0, progress: 0 })
const compatibilityType = ref<CompatibilityType | null>(null)
const selectedProjectId = ref('')
const selectedVersionId = ref('')
const supportedGameVersions = ref<string[]>([])
const recommendedGameVersion = ref<string | null>(null)
const customModpackFile = ref<File | null>(null)
const hasLicensePermission = ref(false)
const isEditingExistingCompatibility = ref(false)
const isSwitchingCompatibilityType = ref(false)
async function uploadCustomModpackFile(file: File): Promise<Labrinth.Versions.v3.Version> {
const rawFile = toRaw(file)
// Default to filename if we can't parse the mrpack
let versionName = rawFile.name.replace(/\.(zip|mrpack)$/i, '')
let versionNumber = versionName
const loaders: string[] = []
let gameVersions: string[] = []
try {
const zip = await JSZip.loadAsync(rawFile)
const indexFile = zip.file('modrinth.index.json')
if (indexFile) {
const indexContent = await indexFile.async('text')
const metadata = JSON.parse(indexContent) as {
name?: string
versionId?: string
dependencies?: Record<string, string>
}
if (metadata.name) {
versionName = metadata.name
}
if (metadata.versionId) {
versionNumber = metadata.versionId
}
if (metadata.dependencies) {
if ('forge' in metadata.dependencies) loaders.push('forge')
if ('neoforge' in metadata.dependencies) loaders.push('neoforge')
if ('fabric-loader' in metadata.dependencies) loaders.push('fabric')
if ('quilt-loader' in metadata.dependencies) loaders.push('quilt')
if (metadata.dependencies.minecraft) {
gameVersions = [metadata.dependencies.minecraft]
}
}
}
} catch {
console.warn('Could not parse modrinth.index.json from mrpack')
}
const draftVersion: Labrinth.Versions.v3.DraftVersion = {
project_id: projectV3.value.id,
name: versionName,
version_number: versionNumber,
version_type: 'release',
loaders,
game_versions: gameVersions,
featured: false,
status: 'listed',
changelog: '',
dependencies: [],
environment: 'client_and_server',
}
const files: Labrinth.Versions.v3.DraftVersionFile[] = [{ file: rawFile, fileType: undefined }]
const uploadHandle = labrinth.versions_v3.createVersion(draftVersion, files, 'modpack')
uploadHandle.onProgress((progress) => {
uploadProgress.value = progress
})
return await uploadHandle.promise
}
async function handleSave() {
isSubmitting.value = true
setTimeout(() => {
if (compatibilityType.value === 'custom-modpack' && isSubmitting.value === true) {
isUploading.value = true
uploadProgress.value = { loaded: 0, total: 0, progress: 0 }
}
}, 1500)
try {
switch (compatibilityType.value) {
case 'vanilla':
await patchProjectV3({
minecraft_java_server: {
content: {
kind: 'vanilla',
supported_game_versions: supportedGameVersions.value,
recommended_game_version: recommendedGameVersion.value,
},
},
})
break
case 'published-modpack':
await patchProjectV3({
minecraft_java_server: {
content: {
kind: 'modpack',
version_id: selectedVersionId.value,
},
},
})
break
case 'custom-modpack': {
if (!customModpackFile.value) break
// Upload the modpack file as a new version
let uploadedVersion: Labrinth.Versions.v3.Version
uploadedVersion = await uploadCustomModpackFile(customModpackFile.value)
// Patch the project to point to the newly uploaded version
try {
await patchProjectV3({
minecraft_java_server: {
content: {
kind: 'modpack',
version_id: uploadedVersion.id,
},
},
})
} catch (err) {
// If patch fails, clean up the uploaded version
try {
await labrinth.versions_v3.deleteVersion(uploadedVersion.id)
} catch {
console.error('Failed to clean up uploaded version after patch failure')
}
throw err
}
break
}
}
isUploading.value = false
isSubmitting.value = false
await nextTick()
modal.value?.hide()
} catch (err) {
isUploading.value = false
isSubmitting.value = false
const error = err as { data?: { description?: string } }
addNotification({
title: 'Failed to save server compatibility',
text: error.data?.description || String(err),
type: 'error',
})
}
}
function resetContext() {
compatibilityType.value = null
selectedProjectId.value = ''
selectedVersionId.value = ''
supportedGameVersions.value = []
recommendedGameVersion.value = null
customModpackFile.value = null
hasLicensePermission.value = false
isEditingExistingCompatibility.value = false
isSwitchingCompatibilityType.value = false
}
return {
stageConfigs,
modal,
isSubmitting,
isUploading,
uploadProgress,
compatibilityType,
selectedProjectId,
selectedVersionId,
supportedGameVersions,
recommendedGameVersion,
customModpackFile,
hasLicensePermission,
isEditingExistingCompatibility,
isSwitchingCompatibilityType,
resetContext,
handleSave,
}
}
const selectCompatibilityTypeStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'select-compatibility-type',
stageContent: markRaw(SelectCompatibilityType),
title: 'Compatibility type',
cannotNavigateForward: (ctx) => !ctx.compatibilityType.value,
leftButtonConfig: null,
rightButtonConfig: null,
}
const selectVanillaVersionsStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'select-vanilla-versions',
stageContent: markRaw(SelectVanillaVersions),
title: 'Vanilla versions',
skip: (ctx) => ctx.compatibilityType.value !== 'vanilla' && !!ctx.compatibilityType.value,
leftButtonConfig: (ctx) =>
ctx.isEditingExistingCompatibility.value
? {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.isSwitchingCompatibilityType.value
? {
label: 'Change type',
icon: ArrowLeftRightIcon,
iconPosition: 'before' as const,
color: 'red' as const,
disabled:
ctx.isSubmitting.value ||
ctx.supportedGameVersions.value.length === 0 ||
!ctx.recommendedGameVersion.value,
onClick: () => ctx.handleSave(),
}
: {
label: ctx.isSubmitting.value
? ctx.isEditingExistingCompatibility.value
? 'Updating…'
: 'Saving…'
: ctx.isEditingExistingCompatibility.value
? 'Save changes'
: 'Save',
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'green' as const,
disabled:
ctx.isSubmitting.value ||
ctx.supportedGameVersions.value.length === 0 ||
!ctx.recommendedGameVersion.value,
onClick: () => ctx.handleSave(),
},
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
}
const selectPublishedModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'select-published-modpack',
stageContent: markRaw(SelectPublishedModpack),
title: 'Select modpack',
skip: (ctx) => ctx.compatibilityType.value !== 'published-modpack',
cannotNavigateForward: (ctx) => !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
leftButtonConfig: (ctx) =>
ctx.isEditingExistingCompatibility.value
? {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.isSwitchingCompatibilityType.value
? {
label: 'Change type',
icon: ArrowLeftRightIcon,
iconPosition: 'before' as const,
color: 'red' as const,
disabled:
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
onClick: () => ctx.handleSave(),
}
: {
label: ctx.isSubmitting.value
? ctx.isEditingExistingCompatibility.value
? 'Updating…'
: 'Saving…'
: ctx.isEditingExistingCompatibility.value
? 'Save changes'
: 'Save',
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'green' as const,
disabled:
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
onClick: () => ctx.handleSave(),
},
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
}
function getUploadLabel(ctx: ServerCompatibilityContextValue): string {
if (ctx.isUploading.value) {
if (ctx.uploadProgress.value.progress >= 1) {
return 'Saving…'
}
return `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
}
if (ctx.isSubmitting.value) {
return ctx.isEditingExistingCompatibility.value ? 'Updating…' : 'Saving…'
}
return ctx.isEditingExistingCompatibility.value ? 'Save changes' : 'Save'
}
const uploadCustomModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'upload-custom-modpack',
stageContent: markRaw(UploadCustomModpack),
title: 'Custom modpack',
skip: (ctx) => ctx.compatibilityType.value !== 'custom-modpack',
disableClose: (ctx) => ctx.isUploading.value,
leftButtonConfig: (ctx) =>
ctx.isEditingExistingCompatibility.value
? {
label: 'Cancel',
icon: XIcon,
disabled: ctx.isUploading.value,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
disabled: ctx.isUploading.value,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.isSwitchingCompatibilityType.value
? {
label: ctx.isUploading.value
? `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
: 'Change type',
icon: ctx.isSubmitting.value ? SpinnerIcon : ArrowLeftRightIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'red' as const,
disabled:
ctx.isSubmitting.value ||
!ctx.customModpackFile.value ||
!ctx.hasLicensePermission.value,
onClick: () => ctx.handleSave(),
buttonClass: 'tabular-nums',
}
: {
label: getUploadLabel(ctx),
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'green' as const,
disabled:
ctx.isSubmitting.value ||
!ctx.customModpackFile.value ||
!ctx.hasLicensePermission.value,
onClick: () => ctx.handleSave(),
buttonClass: 'tabular-nums',
},
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
}
const stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[] = [
selectCompatibilityTypeStage,
selectVanillaVersionsStage,
selectPublishedModpackStage,
uploadCustomModpackStage,
]
-2
View File
@@ -1,2 +0,0 @@
User-agent: *
Disallow: /
@@ -41,6 +41,14 @@ const projectTypeMessages = defineMessages({
id: 'project-type.resourcepack.plural',
defaultMessage: 'Resource Packs',
},
server: {
id: 'project-type.server.singular',
defaultMessage: 'Server',
},
servers: {
id: 'project-type.server.plural',
defaultMessage: 'Servers',
},
shader: {
id: 'project-type.shader.singular',
defaultMessage: 'Shader',
+2
View File
@@ -157,3 +157,5 @@ MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
+3
View File
@@ -167,3 +167,6 @@ MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
SERVER_PING_MIN_INTERVAL_SEC=1800
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ",
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -16,10 +16,11 @@
"Varchar",
"Bool",
"Varchar",
"Int4"
"Int4",
"Jsonb"
]
},
"nullable": []
},
"hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce"
"hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET components = $1\n WHERE id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Int8"
]
},
"nullable": []
},
"hash": "46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, components AS \"components: Json<exp::ProjectSerial>\"\n FROM mods\n WHERE components ? 'minecraft_java_server'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "components: Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "591bf29b883b66b111496f8c07ebd2619cd3bcf92f637ca99876c14bd44c85f6"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,\n m.components AS \"components: sqlx::types::Json<exp::ProjectSerial>\"\n\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id\n ",
"describe": {
"columns": [
{
@@ -137,6 +137,11 @@
"ordinal": 26,
"name": "additional_categories",
"type_info": "VarcharArray"
},
{
"ordinal": 27,
"name": "components: sqlx::types::Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -172,8 +177,9 @@
false,
false,
null,
null
null,
false
]
},
"hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62"
"hash": "59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering\n FROM versions v\n WHERE v.id = ANY($1);\n ",
"query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n v.components AS \"components: sqlx::types::Json<exp::VersionSerial>\"\n FROM versions v\n WHERE v.id = ANY($1);\n ",
"describe": {
"columns": [
{
@@ -67,6 +67,11 @@
"ordinal": 12,
"name": "ordering",
"type_info": "Int4"
},
{
"ordinal": 13,
"name": "components: sqlx::types::Json<exp::VersionSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -87,8 +92,9 @@
false,
false,
true,
true
true,
false
]
},
"hash": "32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6"
"hash": "760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.updated updated, m.approved approved, m.published, m.license license, m.slug slug, m.color\n FROM mods m\n WHERE m.status = ANY($1) AND m.id > $3\n GROUP BY m.id\n ORDER BY m.id ASC\n LIMIT $2;\n ",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.updated updated, m.approved approved, m.published, m.license license, m.slug slug, m.color,\n m.components AS \"components: sqlx::types::Json<exp::ProjectSerial>\"\n FROM mods m\n WHERE m.status = ANY($1) AND m.id > $3\n GROUP BY m.id\n ORDER BY m.id ASC\n LIMIT $2;\n ",
"describe": {
"columns": [
{
@@ -62,6 +62,11 @@
"ordinal": 11,
"name": "color",
"type_info": "Int4"
},
{
"ordinal": 12,
"name": "components: sqlx::types::Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -83,8 +88,9 @@
false,
false,
true,
true
true,
false
]
},
"hash": "702a2826d5857dc51b1a7a79c9043ae8987441bb5e89c9ea62d347e47899e3c2"
"hash": "b760951120c1885530f3fb8208a5874fc472ec160a673abeeee10c778b9cf23f"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18\n )\n ",
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18,\n $19\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -22,10 +22,11 @@
"Int4",
"Varchar",
"Int8",
"Varchar"
"Varchar",
"Jsonb"
]
},
"nullable": []
},
"hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702"
"hash": "cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, slug FROM mods\n WHERE id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "slug",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
true
]
},
"hash": "f3da0156702de4b91b6cf3cb4c34c38807b2594efab47312066c71b85897fec5"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n v.id AS \"version_id: DBVersionId\",\n m.id AS \"project_id: DBProjectId\",\n m.name AS \"project_name!\",\n COALESCE(m.icon_url, '') AS \"project_icon!\"\n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n WHERE v.id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "version_id: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "project_name!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "project_icon!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
false,
false,
null
]
},
"hash": "fc99699ff89c3245c7288718ff15d16058b00bf5df286923c0c6b1db2dbbde4f"
}
+1
View File
@@ -21,6 +21,7 @@ actix-ws = { workspace = true }
arc-swap = { workspace = true }
argon2 = { workspace = true }
ariadne = { workspace = true }
async-minecraft-ping = { workspace = true }
async-stripe = { workspace = true, features = [
"billing",
"checkout",
@@ -0,0 +1,5 @@
-- mistake from previous migration `20260218183440_server_project_links_categories`
DELETE FROM categories
WHERE
header = 'minecraft_server_community'
AND category = 'adventure-mode';
+108 -87
View File
@@ -1,5 +1,6 @@
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::queue::analytics::cache::cache_analytics;
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::email::EmailQueue;
use crate::queue::payouts::{
@@ -11,7 +12,8 @@ use crate::search::indexing::index_projects;
use crate::util::anrok;
use crate::{database, search};
use clap::ValueEnum;
use tracing::{error, info, warn};
use eyre::WrapErr;
use tracing::info;
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
#[clap(rename_all = "kebab_case")]
@@ -25,6 +27,12 @@ pub enum BackgroundTask {
IndexSubscriptions,
Migrations,
Mail,
/// Queries server project analytics (e.g. number of verified plays in last
/// 2 weeks for server projects) and caches them in Redis.
CacheAnalytics,
/// Attempts to ping Minecraft Java servers as if we were a client, to
/// collect info on if they're online, game version, description, etc.
PingMinecraftJavaServers,
}
impl BackgroundTask {
@@ -40,7 +48,7 @@ impl BackgroundTask {
anrok_client: anrok::Client,
email_queue: EmailQueue,
mural_client: muralpay::Client,
) {
) -> eyre::Result<()> {
use BackgroundTask::*;
match self {
Migrations => run_migrations().await,
@@ -62,7 +70,7 @@ impl BackgroundTask {
)
.await;
update_bank_balances(pool).await;
update_bank_balances(pool).await
}
IndexSubscriptions => {
index_subscriptions(
@@ -71,71 +79,69 @@ impl BackgroundTask {
stripe_client,
anrok_client,
)
.await
.await;
Ok(())
}
Mail => {
run_email(email_queue).await;
Mail => run_email(email_queue).await,
CacheAnalytics => {
cache_analytics(&pool, &redis_pool, &clickhouse).await
}
PingMinecraftJavaServers => {
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
}
}
}
}
pub async fn run_email(email_queue: EmailQueue) {
pub async fn run_email(email_queue: EmailQueue) -> eyre::Result<()> {
// Only index for 5 emails at a time, to reduce transaction length,
// for a total of 100 emails.
for _ in 0..20 {
let then = std::time::Instant::now();
match email_queue.index(5).await {
Ok(true) => {
info!(
"Indexed email queue in {}ms",
then.elapsed().as_millis()
);
}
Ok(false) => {
info!("No more emails to index");
break;
}
Err(error) => {
error!(%error, "Failed to index email queue");
}
let indexed = email_queue
.index(5)
.await
.wrap_err("failed to index email queue")?;
if indexed {
info!("Indexed email queue in {}ms", then.elapsed().as_millis());
} else {
info!("No more emails to index");
break;
}
}
Ok(())
}
pub async fn update_bank_balances(pool: PgPool) {
pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> {
let payouts_queue = PayoutsQueue::new();
match insert_bank_balances_and_webhook(&payouts_queue, &pool).await {
Ok(_) => info!("Bank balances updated successfully"),
Err(error) => error!(%error, "Bank balances update failed"),
}
insert_bank_balances_and_webhook(&payouts_queue, &pool)
.await
.wrap_err("failed to update bank balances")?;
info!("Bank balances updated successfully");
Ok(())
}
pub async fn run_migrations() {
database::check_for_migrations()
.await
.expect("An error occurred while running migrations.");
pub async fn run_migrations() -> eyre::Result<()> {
database::check_for_migrations().await?;
Ok(())
}
pub async fn index_search(
ro_pool: PgPool,
redis_pool: RedisPool,
search_config: search::SearchConfig,
) {
) -> eyre::Result<()> {
info!("Indexing local database");
let result = index_projects(ro_pool, redis_pool, &search_config).await;
if let Err(e) = result {
warn!("Local project indexing failed: {:?}", e);
}
info!("Done indexing local database");
index_projects(ro_pool, redis_pool, &search_config).await
}
pub async fn release_scheduled(pool: PgPool) {
pub async fn release_scheduled(pool: PgPool) -> eyre::Result<()> {
info!("Releasing scheduled versions/projects!");
let projects_results = sqlx::query!(
sqlx::query!(
"
UPDATE mods
SET status = requested_status
@@ -143,14 +149,11 @@ pub async fn release_scheduled(pool: PgPool) {
",
crate::models::projects::ProjectStatus::Scheduled.as_str(),
)
.execute(&pool)
.await;
.execute(&pool)
.await
.wrap_err("failed syncing scheduled releases for projects")?;
if let Err(e) = projects_results {
warn!("Syncing scheduled releases for projects failed: {:?}", e);
}
let versions_results = sqlx::query!(
sqlx::query!(
"
UPDATE versions
SET status = requested_status
@@ -158,78 +161,96 @@ pub async fn release_scheduled(pool: PgPool) {
",
crate::models::projects::VersionStatus::Scheduled.as_str(),
)
.execute(&pool)
.await;
if let Err(e) = versions_results {
warn!("Syncing scheduled releases for versions failed: {:?}", e);
}
.execute(&pool)
.await
.wrap_err("failed syncing scheduled releases for versions")?;
info!("Finished releasing scheduled versions/projects");
Ok(())
}
pub async fn update_versions(pool: PgPool, redis_pool: RedisPool) {
pub async fn update_versions(
pool: PgPool,
redis_pool: RedisPool,
) -> eyre::Result<()> {
info!("Indexing game versions list from Mojang");
let result = version_updater::update_versions(&pool, &redis_pool).await;
if let Err(e) = result {
warn!("Version update failed: {}", e);
}
version_updater::update_versions(&pool, &redis_pool)
.await
.wrap_err("failed to update game versions")?;
info!("Done indexing game versions");
Ok(())
}
pub async fn payouts(
pool: PgPool,
clickhouse: clickhouse::Client,
redis_pool: RedisPool,
) {
) -> eyre::Result<()> {
info!("Started running payouts");
let result = process_payout(&pool, &clickhouse).await;
if let Err(e) = result {
warn!("Payouts run failed: {e:#?}");
}
process_payout(&pool, &clickhouse)
.await
.wrap_err("payout processing failed")?;
let result = index_payouts_notifications(&pool, &redis_pool).await;
if let Err(e) = result {
warn!("Payouts notifications indexing failed: {e:#?}");
}
index_payouts_notifications(&pool, &redis_pool)
.await
.wrap_err("payout notifications indexing failed")?;
let result = process_affiliate_payouts(&pool).await;
if let Err(e) = result {
warn!("Affiliate payouts run failed: {e:#?}");
}
process_affiliate_payouts(&pool)
.await
.wrap_err("affiliate payouts processing failed")?;
let result = remove_payouts_for_refunded_charges(&pool).await;
if let Err(e) = result {
warn!("Removing affiliate payouts for refunded charges failed: {e:#?}");
}
remove_payouts_for_refunded_charges(&pool)
.await
.wrap_err("removing payouts for refunded charges failed")?;
info!("Done running payouts");
Ok(())
}
pub async fn sync_payout_statuses(pool: PgPool, mural: muralpay::Client) {
pub async fn sync_payout_statuses(
pool: PgPool,
mural: muralpay::Client,
) -> eyre::Result<()> {
// Mural sets a max limit of 100 for search payouts endpoint
const LIMIT: u32 = 100;
info!("Started syncing payout statuses");
let result = crate::queue::payouts::mural::sync_pending_payouts_from_mural(
crate::queue::payouts::mural::sync_pending_payouts_from_mural(
&pool, &mural, LIMIT,
)
.await;
if let Err(e) = result {
warn!("Failed to sync pending payouts from Mural: {e:?}");
}
.await
.wrap_err("failed to sync pending payouts from Mural")?;
let result =
crate::queue::payouts::mural::sync_failed_mural_payouts_to_labrinth(
&pool, &mural, LIMIT,
)
.await;
if let Err(e) = result {
warn!("Failed to sync failed Mural payouts to Labrinth: {e:?}");
}
crate::queue::payouts::mural::sync_failed_mural_payouts_to_labrinth(
&pool, &mural, LIMIT,
)
.await
.wrap_err("failed to sync failed Mural payouts to Labrinth")?;
info!("Done syncing payout statuses");
Ok(())
}
pub async fn ping_minecraft_java_servers(
pool: PgPool,
redis_pool: RedisPool,
clickhouse: clickhouse::Client,
) -> eyre::Result<()> {
info!("Started pinging Minecraft Java servers");
let server_ping_queue = crate::queue::server_ping::ServerPingQueue::new(
pool, redis_pool, clickhouse,
);
server_ping_queue
.ping_minecraft_java_servers()
.await
.wrap_err("failed to ping Minecraft Java servers")?;
info!("Successfully pinged Minecraft Java servers");
info!("Done pinging Minecraft Java servers");
Ok(())
}
mod version_updater {
+59
View File
@@ -6,6 +6,8 @@ mod fetch;
pub use fetch::*;
use crate::env::ENV;
use crate::queue::server_ping;
use crate::routes::analytics::MINECRAFT_SERVER_PLAYS;
pub async fn init_client() -> clickhouse::error::Result<clickhouse::Client> {
init_client_with_database(&ENV.CLICKHOUSE_DATABASE).await
@@ -14,6 +16,8 @@ pub async fn init_client() -> clickhouse::error::Result<clickhouse::Client> {
pub async fn init_client_with_database(
database: &str,
) -> clickhouse::error::Result<clickhouse::Client> {
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
let client = {
let https_connector = HttpsConnectorBuilder::new()
.with_native_roots()?
@@ -160,5 +164,60 @@ pub async fn init_client_with_database(
.execute()
.await?;
client
.query(&format!(
"
CREATE TABLE IF NOT EXISTS {database}.{MINECRAFT_JAVA_SERVER_PINGS} {cluster_line}
(
recorded DateTime64(4),
project_id UInt64,
address String,
port UInt16,
online Bool,
latency_ms Nullable(UInt32),
description Nullable(String),
version_name Nullable(String),
version_protocol Nullable(UInt32),
players_online Nullable(UInt32),
players_max Nullable(UInt32)
)
ENGINE = {engine}
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
"
))
.execute()
.await?;
client
.query(&format!(
"
CREATE TABLE IF NOT EXISTS {database}.{MINECRAFT_SERVER_PLAYS} {cluster_line}
(
recorded DateTime64(4),
user_id UInt64,
project_id UInt64,
minecraft_uuid UUID
)
ENGINE = {engine}
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
"
))
.execute()
.await?;
client
.query(&format!(
"
ALTER TABLE {database}.{MINECRAFT_SERVER_PLAYS} {cluster_line}
ADD COLUMN IF NOT EXISTS minecraft_uuid UUID
"
))
.execute()
.await?;
Ok(client.with_database(database))
}
+2
View File
@@ -58,6 +58,8 @@ pub use moderation_lock_item::{DBModerationLock, ModerationLockWithUser};
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error(transparent)]
Internal(#[from] eyre::Report),
#[error("Error while interacting with the database: {0}")]
Database(#[from] sqlx::Error),
#[error("Error while trying to generate random ID")]
+116 -39
View File
@@ -6,9 +6,13 @@ use super::{DBUser, ids::*};
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::database::{PgTransaction, models};
use crate::models::exp;
use crate::models::ids::ProjectId;
use crate::models::projects::{
MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus,
};
use crate::routes::ApiError;
use crate::util::error::Context;
use ariadne::ids::base62_impl::parse_base62;
use chrono::{DateTime, Utc};
use dashmap::{DashMap, DashSet};
@@ -176,6 +180,7 @@ pub struct ProjectBuilder {
pub gallery_items: Vec<DBGalleryItem>,
pub color: Option<u32>,
pub monetization_status: MonetizationStatus,
pub components: exp::ProjectSerial,
}
impl ProjectBuilder {
@@ -215,6 +220,7 @@ impl ProjectBuilder {
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
loaders: vec![],
components: self.components,
};
project_struct.insert(&mut *transaction).await?;
@@ -294,6 +300,7 @@ pub struct DBProject {
pub monetization_status: MonetizationStatus,
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
pub loaders: Vec<String>,
pub components: exp::ProjectSerial,
}
impl DBProject {
@@ -308,14 +315,16 @@ impl DBProject {
published, downloads, icon_url, raw_icon_url, status, requested_status,
license_url, license,
slug, color, monetization_status, organization_id,
side_types_migration_review_status
side_types_migration_review_status,
components
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13,
LOWER($14), $15, $16, $17,
$18
$18,
$19
)
",
self.id as DBProjectId,
@@ -335,7 +344,8 @@ impl DBProject {
self.color.map(|x| x as i32),
self.monetization_status.as_str(),
self.organization_id.map(|x| x.0 as i64),
self.side_types_migration_review_status.as_str()
self.side_types_migration_review_status.as_str(),
serde_json::to_value(&self.components).expect("serialization shouldn't fail"),
)
.execute(&mut *transaction)
.await?;
@@ -347,12 +357,15 @@ impl DBProject {
id: DBProjectId,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<Option<()>, DatabaseError> {
let project = Self::get_id(id, &mut *transaction, redis).await?;
) -> Result<Option<()>, ApiError> {
let project = Self::get_id(id, &mut *transaction, redis)
.await
.wrap_internal_err("failed to fetch project by ID")?;
if let Some(project) = project {
DBProject::clear_cache(id, project.inner.slug, Some(true), redis)
.await?;
.await
.wrap_internal_err("failed to clear project cache")?;
sqlx::query!(
"
@@ -362,7 +375,8 @@ impl DBProject {
id as DBProjectId
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project followers")?;
sqlx::query!(
"
@@ -372,7 +386,8 @@ impl DBProject {
id as DBProjectId
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project gallery items")?;
sqlx::query!(
"
@@ -382,7 +397,10 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err(
"failed to delete duplicate project followers",
)?;
sqlx::query!(
"
@@ -393,7 +411,8 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to clear report project references")?;
sqlx::query!(
"
@@ -403,7 +422,8 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project categories")?;
sqlx::query!(
"
@@ -413,11 +433,13 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project links")?;
for version in project.versions {
super::DBVersion::remove_full(version, redis, transaction)
.await?;
.await
.wrap_internal_err("failed to remove project version")?;
}
sqlx::query!(
@@ -427,7 +449,8 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete dependency references")?;
sqlx::query!(
"
@@ -438,7 +461,8 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to clear payout project references")?;
sqlx::query!(
"
@@ -448,10 +472,12 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete project row")?;
models::DBTeamMember::clear_cache(project.inner.team_id, redis)
.await?;
.await
.wrap_internal_err("failed to clear team member cache")?;
let affected_user_ids = sqlx::query!(
"
@@ -464,9 +490,12 @@ impl DBProject {
.fetch(&mut *transaction)
.map_ok(|x| DBUserId(x.user_id))
.try_collect::<Vec<_>>()
.await?;
.await
.wrap_internal_err("failed to delete team members")?;
DBUser::clear_project_cache(&affected_user_ids, redis).await?;
DBUser::clear_project_cache(&affected_user_ids, redis)
.await
.wrap_internal_err("failed to clear user project cache")?;
sqlx::query!(
"
@@ -476,7 +505,8 @@ impl DBProject {
project.inner.team_id as DBTeamId,
)
.execute(&mut *transaction)
.await?;
.await
.wrap_internal_err("failed to delete team")?;
Ok(Some(()))
} else {
@@ -488,7 +518,7 @@ impl DBProject {
string: &str,
executor: E,
redis: &RedisPool,
) -> Result<Option<ProjectQueryResult>, DatabaseError>
) -> Result<Option<ProjectQueryResult>, ApiError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -501,7 +531,7 @@ impl DBProject {
id: DBProjectId,
executor: E,
redis: &RedisPool,
) -> Result<Option<ProjectQueryResult>, DatabaseError>
) -> Result<Option<ProjectQueryResult>, ApiError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -518,7 +548,7 @@ impl DBProject {
project_ids: &[DBProjectId],
exec: E,
redis: &RedisPool,
) -> Result<Vec<ProjectQueryResult>, DatabaseError>
) -> Result<Vec<ProjectQueryResult>, ApiError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -537,7 +567,7 @@ impl DBProject {
project_strings: &[T],
exec: E,
redis: &RedisPool,
) -> Result<Vec<ProjectQueryResult>, DatabaseError>
) -> Result<Vec<ProjectQueryResult>, ApiError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -547,7 +577,9 @@ impl DBProject {
false,
project_strings,
|ids| async move {
let mut exec = exec.acquire().await?;
let mut exec = exec
.acquire()
.await?;
let project_ids_parsed: Vec<i64> = ids
.iter()
.filter_map(|x| parse_base62(&x.to_string()).ok())
@@ -668,7 +700,8 @@ impl DBProject {
});
async move { Ok(acc) }
}
).await?;
)
.await?;
let links: DashMap<DBProjectId, Vec<LinkUrl>> = sqlx::query!(
"
@@ -692,7 +725,8 @@ impl DBProject {
});
async move { Ok(acc) }
}
).await?;
)
.await?;
#[derive(Default)]
struct VersionLoaderData {
@@ -743,7 +777,9 @@ impl DBProject {
(project_id, version_loader_data)
}
).try_collect().await?;
)
.try_collect()
.await?;
let loader_fields: Vec<QueryLoaderField> = sqlx::query!(
"
@@ -766,8 +802,8 @@ impl DBProject {
.try_collect()
.await?;
let projects = sqlx::query!(
"
let project_rows = sqlx::query!(
r#"
SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,
m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,
m.approved approved, m.queued, m.status status, m.requested_status requested_status,
@@ -777,24 +813,48 @@ impl DBProject {
t.id thread_id, m.monetization_status monetization_status,
m.side_types_migration_review_status side_types_migration_review_status,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,
m.components AS "components: sqlx::types::Json<exp::ProjectSerial>"
FROM mods m
INNER JOIN threads t ON t.mod_id = m.id
LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id
LEFT JOIN categories c ON mc.joining_category_id = c.id
WHERE m.id = ANY($1) OR m.slug = ANY($2)
GROUP BY t.id, m.id;
",
GROUP BY t.id, m.id
"#,
&project_ids_parsed,
&slugs,
)
.fetch(&mut exec)
.try_fold(DashMap::new(), |acc, m| {
.fetch_all(&mut exec)
.await?;
let project_components = project_rows
.iter()
.map(|row| {
(
ProjectId::from(DBProjectId(row.id)),
&row.components.0,
)
})
.collect::<Vec<_>>();
let project_query_context = exp::project::fetch_query_context(
&project_components,
&mut exec,
redis,
)
.await
.wrap_err("failed to fetch project query context")?;
let projects = project_rows
.into_iter()
.try_fold(DashMap::new(), |acc, m| -> Result<_, DatabaseError> {
let id = m.id;
let project_id = DBProjectId(id);
let VersionLoaderData {
loaders,
project_types,
mut project_types,
games,
loader_loader_field_ids,
} = loaders_ptypes_games.remove(&project_id).map(|x|x.1).unwrap_or_default();
@@ -809,6 +869,17 @@ impl DBProject {
.filter(|x| loader_loader_field_ids.contains(&x.id))
.collect::<Vec<_>>();
let components_serial = m.components.0;
exp::compat::correct_project_types(
&components_serial,
&mut project_types,
);
let components = components_serial.clone().into_query(
ProjectId::from(project_id),
&project_query_context,
)
.wrap_err("failed to populate query components")?;
let project = ProjectQueryResult {
inner: DBProject {
id: DBProjectId(id),
@@ -845,6 +916,7 @@ impl DBProject {
&m.side_types_migration_review_status,
),
loaders,
components: components_serial,
},
categories: m.categories.unwrap_or_default(),
additional_categories: m.additional_categories.unwrap_or_default(),
@@ -858,16 +930,19 @@ impl DBProject {
urls,
aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true),
thread_id: DBThreadId(m.thread_id),
components,
};
acc.insert(m.id, (m.slug, project));
async move { Ok(acc) }
Ok(acc)
})
.await?;
?;
Ok(projects)
},
).await?;
)
.await
.wrap_internal_err("failed to fetch cached projects")?;
Ok(val)
}
@@ -983,4 +1058,6 @@ pub struct ProjectQueryResult {
pub gallery_items: Vec<DBGalleryItem>,
pub thread_id: DBThreadId,
pub aggregate_version_fields: Vec<VersionField>,
#[serde(flatten)]
pub components: exp::ProjectQuery,
}
@@ -6,6 +6,7 @@ use crate::database::models::loader_fields::{
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
};
use crate::database::redis::RedisPool;
use crate::models::exp;
use crate::models::projects::{FileType, VersionStatus};
use crate::routes::internal::delphi::DelphiRunParameters;
use chrono::{DateTime, Utc};
@@ -37,6 +38,7 @@ pub struct VersionBuilder {
pub status: VersionStatus,
pub requested_status: Option<VersionStatus>,
pub ordering: Option<i32>,
pub components: exp::VersionSerial,
}
#[derive(Clone)]
@@ -206,6 +208,7 @@ impl VersionBuilder {
status: self.status,
requested_status: self.requested_status,
ordering: self.ordering,
components: self.components,
};
version.insert(transaction).await?;
@@ -285,7 +288,7 @@ impl DBLoaderVersion {
}
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Deserialize, Serialize)]
pub struct DBVersion {
pub id: DBVersionId,
pub project_id: DBProjectId,
@@ -300,8 +303,17 @@ pub struct DBVersion {
pub status: VersionStatus,
pub requested_status: Option<VersionStatus>,
pub ordering: Option<i32>,
pub components: exp::VersionSerial,
}
impl PartialEq for DBVersion {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for DBVersion {}
impl DBVersion {
pub async fn insert(
&self,
@@ -312,12 +324,14 @@ impl DBVersion {
INSERT INTO versions (
id, mod_id, author_id, name, version_number,
changelog, date_published, downloads,
version_type, featured, status, ordering
version_type, featured, status, ordering,
components
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10, $11, $12
$9, $10, $11, $12,
$13
)
",
self.id as DBVersionId,
@@ -331,7 +345,9 @@ impl DBVersion {
&self.version_type,
self.featured,
self.status.as_str(),
self.ordering
self.ordering,
serde_json::to_value(&self.components)
.expect("serialization shouldn't fail"),
)
.execute(&mut *transaction)
.await?;
@@ -719,13 +735,14 @@ impl DBVersion {
).await?;
let res = sqlx::query!(
"
r#"
SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,
v.changelog changelog, v.date_published date_published, v.downloads downloads,
v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering
v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,
v.components AS "components: sqlx::types::Json<exp::VersionSerial>"
FROM versions v
WHERE v.id = ANY($1);
",
"#,
&version_ids
)
.fetch(&mut exec)
@@ -746,6 +763,7 @@ impl DBVersion {
.filter(|x| loader_loader_field_ids.contains(&x.id))
.collect::<Vec<_>>();
let components_serial = v.components.0;
let query_version = VersionQueryResult {
inner: DBVersion {
id: DBVersionId(v.id),
@@ -762,6 +780,7 @@ impl DBVersion {
requested_status: v.requested_status
.map(|x| VersionStatus::from_string(&x)),
ordering: v.ordering,
components: components_serial,
},
files: {
let mut files = files.into_iter().map(|x| {
@@ -804,6 +823,8 @@ impl DBVersion {
project_types,
games,
dependencies,
// TODO populate
components: exp::VersionQuery::default(),
};
acc.insert(v.id, query_version);
@@ -937,7 +958,7 @@ impl DBVersion {
}
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Deserialize, Serialize)]
pub struct VersionQueryResult {
pub inner: DBVersion,
@@ -947,6 +968,8 @@ pub struct VersionQueryResult {
pub project_types: Vec<String>,
pub games: Vec<String>,
pub dependencies: Vec<DependencyQueryResult>,
#[serde(flatten)]
pub components: exp::VersionQuery,
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
@@ -993,6 +1016,14 @@ impl std::cmp::PartialOrd for VersionQueryResult {
}
}
impl std::cmp::PartialEq for VersionQueryResult {
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner
}
}
impl std::cmp::Eq for VersionQueryResult {}
impl std::cmp::Ord for DBVersion {
fn cmp(&self, other: &Self) -> Ordering {
let ordering_order = match (self.ordering, other.ordering) {
@@ -1061,6 +1092,7 @@ mod tests {
featured: false,
status: VersionStatus::Listed,
requested_status: None,
components: exp::VersionSerial::default(),
}
}
}
+6
View File
@@ -277,4 +277,10 @@ vars! {
DELPHI_SLACK_WEBHOOK: String = "";
TREMENDOUS_CAMPAIGN_ID: String = "";
// server pinging
SERVER_PING_MAX_CONCURRENT: usize = 16usize;
SERVER_PING_RETRIES: usize = 3usize;
SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60;
SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000;
}
+19 -5
View File
@@ -120,12 +120,15 @@ pub fn app_setup(
let redis_pool_ref = redis_pool_ref.clone();
let search_config_ref = search_config_ref.clone();
async move {
background_task::index_search(
if let Err(e) = background_task::index_search(
pool_ref,
redis_pool_ref,
search_config_ref,
)
.await;
.await
{
warn!("Local project indexing failed: {e:#}");
}
}
});
@@ -135,7 +138,11 @@ pub fn app_setup(
scheduler.run(Duration::from_secs(60 * 5), move || {
let pool_ref = pool_ref.clone();
async move {
background_task::release_scheduled(pool_ref).await;
if let Err(e) =
background_task::release_scheduled(pool_ref).await
{
warn!("Syncing scheduled releases failed: {e:#}");
}
}
});
@@ -147,7 +154,9 @@ pub fn app_setup(
let pool_ref = pool_ref.clone();
let redis = redis_pool_ref.clone();
async move {
update_versions(pool_ref, redis).await;
if let Err(e) = update_versions(pool_ref, redis).await {
warn!("Version update failed: {e:#}");
}
}
});
@@ -159,7 +168,12 @@ pub fn app_setup(
let client_ref = client_ref.clone();
let redis_ref = redis_pool_ref.clone();
async move {
background_task::payouts(pool_ref, client_ref, redis_ref).await;
if let Err(e) =
background_task::payouts(pool_ref, client_ref, redis_ref)
.await
{
warn!("Payout task failed: {e:#}");
}
}
});
+2 -1
View File
@@ -178,7 +178,8 @@ async fn app() -> std::io::Result<()> {
email_queue,
muralpay,
)
.await;
.await
.map_err(std::io::Error::other)?;
return Ok(());
}

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