feat: linked server instances (#5221)

* ping queue with tests

* mc ping server info + timeout

* sqlx prepare

* tombi fmt

* tombi fmt

* allow querying server ping data

* fix shear

* wip: resolve comments with pings

* Switch to Redis for server pings

* tombi fmt

* fix compile error

* clear cache on project ping, add server store link

* Schema changes

* Improve server messages for app pinging

* synthetic server project version for search indexing

* wip: clean up server ping, background tasks

* fix migration to sync with main, propagate background task errors

* wip: server modpack content query, components in search

* wip: massive component query refactor

* fix more defaults stuff

* sqlx

* fix serde deser flatten

* fix search indexing not showing fields

* remove leftover prompt

* fix import

* add diff detection for version dependencies without version_id/project_id

* move servers tab to end

* hide app nav tabs if only one tab

* fix undefined property

* on click link for server side bar info

* show recommended & supported versions for vanilla

* fix how install.js installs instance with modpack content title instead of server project title and dont fetch icon when installing to existing instance

* use large play button instance

* show update success instead of launching right into the game

* add global installing server project state

* add comment

* small change: open discover to modpack

* implement ping server projects for latency in app

* add projectV3 to nag context for moderation package

* fix play server project button when instance is launched

* add ping to project header

* wip: server verified plays

* server verified plays compiling

* queue up server plays in batches

* report server plays improved in frontend

* fixes to tracking server joins

* fix: server project detection to do loose null check

* fix server projects showing license

* fix empty server info card

* fix server projects links title

* Fix backend impl for server player count analytics

* fix: allow for links to be set to empty

* hook up server recent plays

* cargo sqlx prepare

* add project sidebar stories

* feat: update project sidebar server info card to new design

* update server project header and project card

* feat: add hide label for project cards

* feat: add tags sidebar card

* small fix to keep color consistent

* fix: remove required content tab from server project page

* many small fixes

* handle locking server instance content

* fix hiding modal after saving server compatibility version

* copy content card item and table from content tab update branch

* fix nav tabs active tag

* fix switching between server instance vs regular instance persisted invalid state

* fix a lot of the bugginess of navtabs when theres hidden/shown tabs between instances. match frontend nav tabs

* hook up backend searchfor frontend in websiet

* fix: server project card tags

* hook up search v3 in app backend for app frontend

* Don't return missing components in project query

* Add game versions to server filters

* move reporting server joins to backend

* send account UUID along with server play analytics

* update java server ping schema

* feat: implement use server search for search sorting and filter facets

* pnpm prepr

* fix game version filter facet

* fix: allow java and bedrock addresses to be deleted

* feat: hook up languages

* Default deserialize `ProjectSerial`

* feat: show server project tags

* small fix on languages multi select

* also default java server content

* fix: update compatibility modal not closing after successful upload

* remove play button in website discovery for servers

* reenable fence in app backend

* update online/offline tag

* add online status indicator pulsing

* revert pulsing

* disable link for custom modpack project and show tooltip

* change modpack to modded type

* update ip address entire button to be clickable

* polish server info card styles

* make offline tag red and properly hook up online tag

* move server related settings into own tab

* fix setting project compatibility resets unsaved changes

* fix javaServerPatchaData wiping content field

* updates to compatibility card, add download button and display supported versions better

* fix unsaved changes popup for tags

* remove console.log

* fix incorrect project type in projects in dashboard

* fix: savable.ts to reset currentValues to data() after save

* upload server banner as gallery image with title == "__mc_server_banner__" and filter it from frontend gallery

* fix error handling and helper text copy

* ensure gallery banners are filtered in app backend gallery display

* add grouped filters for search

* add query params for server search

* feat: deep linking to open server project page then open install to play

* fix search in app frontend

* fix: server project showing offline

* 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.

* pass undefined instead of unknown for modpack content modal

* fix: wrong way to determine offline status

* delete required content page placeholder

* fix: redirect running function instead of passing function

* add in wiki page

* fix diffs which have unknown project/filename

* pnpm prepr

* feat: add handling for "stop" instance state for server project card and page play button

* fix updating modpack shouldn't launch right into game

* small fix on external icon

* 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).

* don't require auth token for logging server play

* fetch latest server player count from redis instead of search doc

* remove components. in search facet

* Category and search sort fixes

* add logging for refreshSearch in browse.vue

* fix: use windows.history.replace instead of router.replace due to vue production bug and remove logs

* fix: server refresh search reactivity

* fix: type errors

* conquer the type errors in Browse.vue

* update search input background

* fix tags location

* slight change to color

* feat: add linked to modpack project for regular modpack instances

* feat: installation tab updates

* fix: copy ip missing hover effect

* feat: implement category and countries negative filters

* fix servers tab label in profile page

* implement add server to instance

* feat: implement allow editing server instances

* update installation settings to handle vanilla server instance case

* hide servers tab when installing content to instance

* add sorting for user installed content to be top of list in content

* update categories filters from one group filter card to separate filters cards

* add active scale

* fix offline server showing online

* update language display

* update tooltip

* hide navtabs if theres only one tab

* fix: modpack content name truncate in project card

* feat: add server projects to moderation queue

* update redirect middleware no longer needs projectV3

* update comment

* fix: server tags labels

* feat: add the mf icons finally

* Revert "update redirect middleware no longer needs projectV3"

This reverts commit 1289cb52869185abe1481dfb6b0c00c0233bf59e.

* fix open in browser

* revert any handling for handling base linked modpack content for content tab

* update instance online players to be client ping

* fix showing modpack/loader version for server instance in installation settings

* server projects are not marked as modpacks

* skip license check for server projects

* feat: add the concept of linked worlds for server instances and keep in sync with server project

* fix: router.push doesn't add history state, use nagivateTo instead

* fix: get server modpack content wrong link

* update some categories to default collapse

* small fixes

* optional languages & bedrock

* move creator below tags

* sort linked worlds to be first

* add red orange and green ping variants

* bring back content tab

* add download button in required content in app

* fix: server info card loading

* fix: brief flash of normal project before server project stuff loads in

* misc fixes

* invalidate project v3

* fix unused imports

* Quick pass for moderation related changes (#5429)

* filter certain nags out from server projects.

* move add-links nag to links.ts

* first few server related nags

* moderation checklist groundwork

* Prevent undefined stage from appearing on servers.

* add projectV3 to shouldShow callback

* Filter buttons by server project type

* fix, revert private use msg, adjust server & link nags

* starting tags + servers msg

* fix no projectV3

* fix: router.push doesn't add history state, use nagivateTo instead

* Tags nag works with servers now

* support servers' v3 exclusive links

* reupload, and status messages + nag tweaks.

* fixes

* Update tags.vue warning for server projects.

* don't suggest adding a bedrock IP

* Tweak phrasing on servers alert msg

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* only show unique tags in project card

* add projectV3 to cache purge

* fix type: add projectV3 to cache purge

* update caching behaviour for installing

* max 3 plays per user

* accept date_modified and date_created for sorting

* add locking environment filter for server instance and update copy

* custom pack button only shows when needed (#5444)

* expose server pinging route to frontend

* feat: add server field validation with pinging on unfocus

* improve pinging logs

* try another pinging crate

* small fixes

* prefill published project id for updating published project

* fix running app bar for mac

* cargo sqlx prepare

* fix app login avatar

* pnpm prepr

* fix download menu for mac

* FIX CI

* fix lint errors

* cargo fmt

* fix toml

* fix more lint

* add server copy

* more lint

* fix any types

* also ping unlisted and private servers

* fix lint

* remove option for showTypeSelector

* fix cannot read user from undefined

* pnpm prepr

* update pinging to make it better

* update copy

* fix login cache issue

* add project select default icon

* fix: minecraft_java_server not redirecting

* pnpm prepr

* fix required content card in project page for custom modpack

* fix app project cards custom modpacks

* update pre-collapsed for app frontend

* don't send server projects to discord webhook

* add lock icon to linked world managed by server project

* pnpm prepr

* make automod msgs on server projects private

* fix pagination for server projects tab

* fix recent plays copy

* fix sync linked world with server project

* pnpm prepr

* add 0.11.0 changelog

* update date

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
This commit is contained in:
Truman Gao
2026-03-02 15:38:09 -08:00
committed by GitHub
parent 51066c476a
commit 51ceb9d851
318 changed files with 19891 additions and 4524 deletions

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

188
Cargo.lock generated
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"
@@ -1883,7 +1896,7 @@ checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
dependencies = [
"cookie 0.18.1",
"document-features",
"idna",
"idna 1.1.0",
"log",
"publicsuffix",
"serde",
@@ -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"
@@ -2651,6 +2698,24 @@ dependencies = [
"zeroize",
]
[[package]]
name = "elytra-ping"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "086ee116d28d0eb35ae108d1059d135e585dd1254e5df881c5b2505a6d67c445"
dependencies = [
"bytes",
"chrono",
"mc-varint",
"rand 0.9.2",
"serde",
"serde_json",
"snafu",
"tokio",
"tracing",
"trust-dns-resolver",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
@@ -3754,7 +3819,7 @@ dependencies = [
"futures-channel",
"futures-io",
"futures-util",
"idna",
"idna 1.1.0",
"ipnet",
"once_cell",
"rand 0.9.2",
@@ -4224,6 +4289,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "idna"
version = "1.1.0"
@@ -4730,6 +4805,7 @@ dependencies = [
"arc-swap",
"argon2",
"ariadne",
"async-minecraft-ping",
"async-stripe",
"async-trait",
"base64 0.22.1",
@@ -4748,6 +4824,7 @@ dependencies = [
"dotenv-build",
"dotenvy",
"either",
"elytra-ping",
"eyre",
"futures",
"futures-util",
@@ -4811,6 +4888,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"
@@ -4848,7 +4935,7 @@ dependencies = [
"futures-util",
"hostname",
"httpdate",
"idna",
"idna 1.1.0",
"mime",
"nom 8.0.0",
"percent-encoding",
@@ -4974,6 +5061,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -5039,6 +5132,15 @@ dependencies = [
"imgref",
]
[[package]]
name = "lru-cache"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
dependencies = [
"linked-hash-map",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -5150,6 +5252,12 @@ dependencies = [
"rayon",
]
[[package]]
name = "mc-varint"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6407d50d4e371d5f450a4a5f1abd4531e48e9a1627409c051b5d4c7c84f6bb09"
[[package]]
name = "md-5"
version = "0.10.6"
@@ -7070,7 +7178,7 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"idna 1.1.0",
"psl-types",
]
@@ -8776,6 +8884,28 @@ dependencies = [
"serde",
]
[[package]]
name = "snafu"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
dependencies = [
"backtrace",
"snafu-derive",
]
[[package]]
name = "snafu-derive"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "socket2"
version = "0.5.10"
@@ -10629,6 +10759,52 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "trust-dns-proto"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3119112651c157f4488931a01e586aa459736e9d6046d3bd9105ffb69352d374"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"enum-as-inner",
"futures-channel",
"futures-io",
"futures-util",
"idna 0.4.0",
"ipnet",
"once_cell",
"rand 0.8.5",
"smallvec",
"thiserror 1.0.69",
"tinyvec",
"tokio",
"tracing",
"url",
]
[[package]]
name = "trust-dns-resolver"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a3e6c3aff1718b3c73e395d1f35202ba2ffa847c6a62eea0db8fb4cfe30be6"
dependencies = [
"cfg-if",
"futures-util",
"ipconfig",
"lru-cache",
"once_cell",
"parking_lot",
"rand 0.8.5",
"resolv-conf",
"smallvec",
"thiserror 1.0.69",
"tokio",
"tracing",
"trust-dns-proto",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -10848,7 +11024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
dependencies = [
"form_urlencoded",
"idna",
"idna 1.1.0",
"percent-encoding",
"serde",
]
@@ -10996,7 +11172,7 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa"
dependencies = [
"idna",
"idna 1.1.0",
"once_cell",
"regex",
"serde",

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" }
@@ -69,6 +72,7 @@ dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
elytra-ping = "6.0.1"
encoding_rs = "0.8.35"
enumset = "1.1.10"
eyre = "0.6.12"
@@ -121,9 +125,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 +172,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"

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()
@@ -403,7 +414,7 @@ const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
creds.user = await get_user(creds.user_id).catch(handleError)
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
}
credentials.value = creds ?? null
}
@@ -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"
@@ -902,7 +993,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<SettingsIcon />
</NavButton>
<OverflowMenu
v-if="credentials"
v-if="credentials?.user"
v-tooltip.right="`Modrinth account`"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
:options="[
@@ -918,14 +1009,14 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
]"
placement="right-end"
>
<Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
<Avatar :src="credentials?.user?.avatar_url" alt="" size="32px" circle />
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
Signed in as
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials.user.avatar_url" alt="" size="20px" circle />
{{ credentials.user.username }}
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
{{ credentials?.user?.username }}
</span>
</span>
<ExternalIcon />
@@ -1133,11 +1224,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 +1461,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 +1533,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}
.info-card {
right: 8rem;
right: 22rem;
}
.profile-card {

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,6 +72,11 @@ 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
@@ -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)
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',
}
if (newValues.top < sliderTop.value) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
}
}
}
@@ -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>

View File

@@ -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>

View File

@@ -52,12 +52,14 @@
<h3 class="info-title">
{{ loadingBar.title }}
</h3>
<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>
</transition>
<transition name="download">
@@ -346,7 +348,7 @@ onBeforeUnmount(() => {
.info-card {
position: absolute;
top: 3.5rem;
right: 0.5rem;
right: 2rem;
z-index: 9;
width: 20rem;
background-color: var(--color-raised-bg);
@@ -420,7 +422,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
gap: 0.75rem;
margin: 0;
padding: 0;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,121 @@
<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>

View File

@@ -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, 'must_revalidate').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()

View File

@@ -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
: 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) }}

View File

@@ -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 class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{
formatMessage(messages.requiredModpack)
}}</span>
<ButtonStyled type="transparent">
<button @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<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" />
<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">
{{ 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?.name ?? ''"
: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,75 +80,59 @@ 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_project, get_project_many, 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<Labrinth.Versions.v2.Version | null>(null)
const project = ref<Labrinth.Projects.v3.Project | null>(null)
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
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 sharedBy = computed(() => {
if (organization.value) {
return {
name: organization.value.name,
icon_url: organization.value.icon_url,
}
}
if (teamMembers.value) {
const owner = teamMembers.value.find((member: { is_owner: boolean }) => member.is_owner)
if (owner) {
return {
name: owner.user.username,
icon_url: owner.user.avatar_url,
}
}
}
return null
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
})
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')
}
}
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 +140,95 @@ 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: Labrinth.Projects.v2.Project) => [p.id, p]))
const contentItems: ContentItem[] = deps.map(
(dep: Labrinth.Versions.v2.Dependency): ContentItem => {
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
// @ts-expect-error - version_id is missing from the type for some reason
const depVersion = dep.version_id
? // @ts-expect-error - version_id is missing from the type for some reason
versions.find((v: Labrinth.Versions.v2.Version) => 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 ?? undefined,
},
...(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
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 +240,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 +265,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 })

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ChevronRightIcon,
CodeIcon,
@@ -16,7 +17,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 +25,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 +33,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: Labrinth.Projects.v3.Project | undefined) => {
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 +93,7 @@ const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
icon: CodeIcon,
content: HooksSettings,
},
]
])
const modal = ref()
@@ -98,6 +119,10 @@ defineExpose({ show })
</span>
</template>
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
<TabbedModal
:tabs="
tabs.map((tab) => ({ ...tab, props: { ...props, isMinecraftServer: isMinecraftServer } }))
"
/>
</ModalWrapper>
</template>

View File

@@ -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-for="diff in diffs"
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, 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,32 @@
>
{{ 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 +142,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 +162,7 @@ interface BaseDiff {
newVersionId?: string
currentVersion?: Version
newVersion?: Version
fileName?: string
}
interface AddedDiff extends BaseDiff {
type: 'added'
@@ -151,22 +185,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 +210,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 +248,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 +259,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 +282,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 {
@@ -256,34 +310,25 @@ async function computeDependencyDiffs(
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 +339,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 +350,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 +376,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 +440,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>

View File

@@ -28,6 +28,7 @@ watch(
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'version',
'user',
'team',

View File

@@ -25,6 +25,7 @@ import {
defineMessages,
OverflowMenu,
SmartClickable,
TagItem,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -44,7 +45,9 @@ 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'
import { LockIcon } from '../../../../../../packages/assets/generated-icons'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
@@ -117,6 +120,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 +175,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>
@@ -200,6 +208,14 @@ const messages = defineMessages({
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ world.name }}
</div>
<TagItem
v-if="linked"
v-tooltip="formatMessage(messages.linkedServer)"
class="border !border-solid border-blue bg-highlight-blue text-xs"
:style="`--_color: var(--color-blue)`"
>
<LockIcon aria-hidden="true" class="h-5 w-5" />
</TagItem>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold"
@@ -396,8 +412,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 +452,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,
},
]"
>

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 })
}

View File

@@ -18,7 +18,15 @@ 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 +36,7 @@ export async function create(name, gameVersion, modloader, loaderVersion, icon,
loaderVersion,
icon,
skipInstall,
linkedData,
})
}
@@ -174,8 +183,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) {

View File

@@ -139,4 +139,5 @@ type AppSettings = {
export type InstanceSettingsTabProps = {
instance: GameInstance
offline?: boolean
isMinecraftServer?: boolean
}

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(
@@ -150,6 +158,7 @@ export async function edit_server_in_profile(
name: string,
address: string,
packStatus: ServerPackStatus,
linkedProjectId?: string,
): Promise<void> {
return await invoke('plugin:worlds|edit_server_in_profile', {
path,
@@ -157,6 +166,7 @@ export async function edit_server_in_profile(
name,
address,
packStatus,
linkedProjectId,
})
}
@@ -194,6 +204,11 @@ export function getWorldIdentifier(world: World) {
export function sortWorlds(worlds: World[]) {
worlds.sort((a, b) => {
const aLinked = isLinkedWorld(a)
const bLinked = isLinkedWorld(b)
if (aLinked !== bLinked) {
return aLinked ? -1 : 1
}
if (!a.last_played) {
return 1
}
@@ -212,6 +227,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,

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,15 @@
"instance.settings.tabs.installation.unknown-version": {
"message": "(unknown version)"
},
"instance.settings.tabs.installation.unlink-server-vanilla.description": {
"message": "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."
},
"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"
},
@@ -551,6 +560,9 @@
"instance.worlds.incompatible_server": {
"message": "Server is incompatible"
},
"instance.worlds.linked_server": {
"message": "Managed by server project"
},
"instance.worlds.no_contact": {
"message": "Server couldn't be contacted"
},
@@ -586,5 +598,17 @@
},
"search.filter.locked.instance.sync": {
"message": "Sync with instance"
},
"search.filter.locked.server": {
"message": "Provided by the server"
},
"search.filter.locked.server-environment.title": {
"message": "Only client-side mods can be added to the server instance"
},
"search.filter.locked.server-game-version.title": {
"message": "Game version is provided by the server"
},
"search.filter.locked.server-loader.title": {
"message": "Loader is provided by the server"
}
}

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,24 @@ 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_project_v3, 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 type { GameInstance } from '@/helpers/types'
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 +65,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 = {
@@ -60,6 +89,11 @@ type Instance = {
install_stage: string
icon_path?: string
name: string
linked_data?: {
project_id: string
version_id: string
locked: boolean
}
}
type InstanceProject = {
@@ -71,15 +105,19 @@ 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 isServerInstance = ref(false)
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
await updateInstanceContext()
watch(route, () => {
watch(
() => [route.query.i, route.query.ai, route.path],
() => {
updateInstanceContext()
})
},
)
async function updateInstanceContext() {
if (route.query.i) {
@@ -88,6 +126,17 @@ async function updateInstanceContext() {
getInstanceProjects(route.query.i).catch(handleError),
])
newlyInstalled.value = []
isServerInstance.value = false
if (instance.value?.linked_data?.project_id) {
const projectV3 = await get_project_v3(
instance.value.linked_data.project_id,
'must_revalidate',
).catch(handleError)
if (projectV3?.minecraft_server != null) {
isServerInstance.value = true
}
}
}
if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) {
@@ -123,6 +172,13 @@ const instanceFilters = computed(() => {
})
}
if (isServerInstance.value) {
filters.push({
type: 'environment',
option: 'client',
})
}
if (instanceHideInstalled.value && instanceProjects.value) {
const installedMods = Object.values(instanceProjects.value)
.filter((x) => x.metadata)
@@ -164,7 +220,88 @@ 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: GameInstance) => 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: { event: string; profile_path_id: string }) => {
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 +316,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,39 +331,77 @@ 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 (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: 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) {
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 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
}
const currentFilterState = JSON.stringify({
query: query.value,
filters: currentFilters.value,
sort: currentSortType.value,
filters: toRaw(currentFilters.value),
sort: toRaw(currentSortType.value),
maxResults: maxResults.value,
projectTypes: projectTypes.value,
projectTypes: toRaw(projectTypes.value),
})
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
@@ -257,7 +426,7 @@ async function refreshSearch() {
const params = {
...persistentParams,
...createPageParams(),
...(isServer ? createServerPageParams() : createPageParams()),
}
breadcrumbs.setContext({
@@ -265,8 +434,22 @@ async function refreshSearch() {
link: `/browse/${projectType.value}`,
query: params,
})
await router.replace({ path: route.path, 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 +472,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,7 +491,8 @@ const selectableProjectTypes = computed(() => {
if (instance.value) {
if (
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
availableGameVersions.value &&
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
) {
dataPacks = true
@@ -338,6 +522,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) {
@@ -360,23 +545,61 @@ const messages = defineMessages({
id: 'search.filter.locked.instance-game-version.title',
defaultMessage: 'Game version is provided by the instance',
},
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
defaultMessage: 'Game version is provided by the server',
},
modLoaderProvidedByInstance: {
id: 'search.filter.locked.instance-loader.title',
defaultMessage: 'Loader is provided by the instance',
},
modLoaderProvidedByServer: {
id: 'search.filter.locked.server-loader.title',
defaultMessage: 'Loader is provided by the server',
},
environmentProvidedByServer: {
id: 'search.filter.locked.server-environment.title',
defaultMessage: 'Only client-side mods can be added to the server instance',
},
providedByInstance: {
id: 'search.filter.locked.instance',
defaultMessage: 'Provided by the instance',
},
providedByServer: {
id: 'search.filter.locked.server',
defaultMessage: 'Provided by the server',
},
syncFilterButton: {
id: 'search.filter.locked.instance.sync',
defaultMessage: 'Sync with instance',
},
})
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:
project_id !== project.project_id
? () => {
router.push(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === project.project_id,
}
}
return undefined
}
const options = ref(null)
// @ts-expect-error - no event types
const handleRightClick = (event, result) => {
options.value.showMenu(event, result, [
// @ts-ignore
options.value?.showMenu(event, result, [
{
name: 'open_link',
},
@@ -385,6 +608,7 @@ const handleRightClick = (event, result) => {
},
])
}
// @ts-expect-error - no event types
const handleOptionsClick = (args) => {
switch (args.option) {
case 'open_link':
@@ -424,6 +648,32 @@ previousFilterState.value = JSON.stringify({
@click.prevent.stop
/>
</div>
<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="
![
'server_category_minecraft_server_meta',
'server_category_minecraft_server_community',
'server_game_version',
].includes(filterType.id)
"
>
<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}`"
@@ -444,13 +694,29 @@ previousFilterState.value = JSON.stringify({
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByInstance) }}
{{
formatMessage(
isServerInstance
? messages.gameVersionProvidedByServer
: messages.gameVersionProvidedByInstance,
)
}}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByInstance) }}
{{
formatMessage(
isServerInstance
? messages.modLoaderProvidedByServer
: messages.modLoaderProvidedByInstance,
)
}}
</template>
<template #locked-environment>
{{ formatMessage(messages.environmentProvidedByServer) }}
</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 +738,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,46 +766,125 @@ 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"
:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-message="messages.providedByInstance"
:provided-message="isServerInstance ? messages.providedByServer : messages.providedByInstance"
/>
<div class="search">
<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'">
<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"
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)"
:instance="instance ?? undefined"
:installed="result.installed || newlyInstalled.includes(result.project_id || '')"
@install="
(id) => {
newlyInstalled.push(id)
}
"
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
@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>

View File

@@ -1,37 +1,96 @@
<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" />
<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="flex items-center gap-2 font-semibold">
<TimerIcon class="h-6 w-6 text-secondary" />
<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>
</div>
</template>
<template #actions>
<div class="flex gap-2">
@@ -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,94 @@ window.addEventListener('online', () => {
offline.value = false
})
const instance = ref()
const modrinthVersions = ref([])
const instance = ref<GameInstance>()
const modrinthVersions = ref<Labrinth.Versions.v2.Version[]>([])
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: Labrinth.Versions.v2.Version, b: Labrinth.Versions.v2.Version) =>
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: Error) {
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, 'must_revalidate')
const projectId = contentVersion?.project_id
if (projectId) {
modpackContentProjectV3.value = await get_project_v3(projectId, 'must_revalidate')
}
}
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,7 +440,7 @@ watch(
},
)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
const tabs = computed(() => [
{
@@ -293,44 +457,52 @@ const tabs = computed(() => [
},
])
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 +510,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 +534,7 @@ const handleRightClick = (event) => {
{ name: 'copy_path' },
]
options.value.showMenu(
options.value?.showMenu(
event,
instance.value,
playing.value
@@ -372,7 +555,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 +565,30 @@ 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)
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: { profile_path_id: string; event: string }) => {
if (event.profile_path_id === route.params.id) {
if (event.event === 'removed') {
await router.push({
@@ -410,24 +596,29 @@ 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: { event: string; profile_path_id: string }) => {
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,
},
)
const settingsModal = ref()
const icon = computed(() =>
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
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(() => {

View File

@@ -323,6 +323,7 @@ const props = defineProps<{
playing: boolean
versions: Version[]
installed: boolean
isServerInstance?: boolean
}>()
type ProjectListEntryAuthor = {
@@ -352,6 +353,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

View File

@@ -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)

View File

@@ -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)"
/>

View File

@@ -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)"
/>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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,

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,86 @@ 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) {
const primaryFile =
modpackVersion.files?.find((f) => f.primary) ?? modpackVersion.files?.[0]
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,
onclickDownload: primaryFile?.url ? () => openUrl(primaryFile.url) : undefined,
showCustomModpackTooltip: modpackProject.id === project.id,
}
}
}
}
} 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 () => {

View File

@@ -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)
}
}

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 {
create_profile_and_install as packInstall,
install_to_existing_profile,
} 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)
},
},
})
@@ -87,9 +139,10 @@ export const install = async (
callback = () => {},
createInstanceCallback = () => {},
) => {
const project = await get_project(projectId, 'must_revalidate')
const project = await get_project(projectId, 'bypass')
const projectV3 = await get_project_v3(projectId, 'bypass')
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()
@@ -119,7 +172,7 @@ export const install = async (
const [instance, instanceProjects, versions] = await Promise.all([
await get(instancePath),
await get_projects(instancePath),
await get_version_many(project.versions, 'must_revalidate'),
await get_version_many(project.versions, 'bypass'),
])
const projectVersions = versions.sort(
@@ -207,9 +260,9 @@ export const installVersionDependencies = async (profile, version) => {
} else {
if (dep.project_id && (await check_installed(profile.path, dep.project_id))) continue
const depProject = await get_project(dep.project_id, 'must_revalidate')
const depProject = await get_project(dep.project_id, 'bypass')
const depVersions = (await get_version_many(depProject.versions, 'must_revalidate')).sort(
const depVersions = (await get_version_many(depProject.versions, 'bypass')).sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
)
@@ -220,3 +273,310 @@ 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, 'bypass'),
get_project_v3(serverProjectId, 'bypass'),
])
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,
serverProjectId,
)
}
return
}
}
const existingServer = worlds.find((w) => w.type === 'server' && w.address === serverAddress)
if (existingServer) {
// Re-link and sync existing server (link may have been lost by Minecraft rewriting servers.dat)
if (serverProjectId || existingServer.name !== serverName) {
await edit_server_in_profile(
profilePath,
existingServer.index,
serverName,
serverAddress,
existingServer.pack_status,
serverProjectId ?? undefined,
)
}
} else {
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) => {
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, 'bypass'),
get_project_v3(projectId, 'bypass'),
])
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 })
}
}

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(

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()

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)
}

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?;

View File

@@ -151,12 +151,17 @@ 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?,
Ok(worlds::add_server_to_profile(
&path,
name,
address,
pack_status,
linked_project_id,
)
.await?)
}
#[tauri::command]
@@ -166,9 +171,17 @@ pub async fn edit_server_in_profile(
name: String,
address: String,
pack_status: ServerPackStatus,
linked_project_id: Option<String>,
) -> Result<()> {
let path = get_full_path(path).await?;
worlds::edit_server_in_profile(&path, index, name, address, pack_status)
worlds::edit_server_in_profile(
&path,
index,
name,
address,
pack_status,
linked_project_id,
)
.await?;
Ok(())
}

View File

@@ -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);

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 {

View File

@@ -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);
}

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>

View File

@@ -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') {

View File

@@ -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 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">
<label for="slug" class="flex flex-col gap-2.5">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<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>
</div>
<div class="flex flex-col gap-2">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.visibilityLabel) }}
<span class="text-brand-red">*</span>
</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.5">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(commonMessages.visibilityLabel) }}
</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,76 @@
</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'
}
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 +229,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 +245,38 @@ 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 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 +290,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 +375,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 +385,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 +397,70 @@ async function createProject() {
is_draft: true,
}
if (props.organizationId) {
projectData.organization_id = props.organizationId
}
formData.append('data', JSON.stringify(projectData))
try {
await useBaseFetch('project', {
let createdProjectId: string | undefined
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,
'Content-Disposition': formData as unknown as string,
},
})
})) as Labrinth.Projects.v3.Project
createdProjectId = result.id
console.log(createdProjectId)
}
modal.value.hide()
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'
await fetchOrganizations()
modal.value?.show(event)
}
defineExpose({
show,
})
function updatedName() {
if (!manualSlug.value) {
slug.value = name.value

View File

@@ -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,

View File

@@ -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
})

View File

@@ -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 gap-4 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>

View File

@@ -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>

View File

@@ -0,0 +1,76 @@
<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) {
const content = projectV3.value?.minecraft_java_server?.content
if (options?.updateContentKind) {
ctx.compatibilityType.value = options.updateContentKind
ctx.isEditingExistingCompatibility.value = true
// Prefill existing values for vanilla
if (options.updateContentKind === 'vanilla' && content && content.kind === 'vanilla') {
ctx.supportedGameVersions.value = content.supported_game_versions ?? []
ctx.recommendedGameVersion.value = content.recommended_game_version ?? null
}
if (options.updateContentKind === 'published-modpack') {
ctx.selectedProjectId.value = content?.kind === 'modpack' ? content.project_id || '' : ''
}
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>

View File

@@ -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>

View File

@@ -0,0 +1,140 @@
<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
}
},
{ immediate: true },
)
</script>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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'],

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') {

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',

View File

@@ -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"
},
@@ -2132,6 +2162,12 @@
"project-type.resourcepack.singular": {
"message": "Resource Pack"
},
"project-type.server.plural": {
"message": "Servers"
},
"project-type.server.singular": {
"message": "Server"
},
"project-type.shader.plural": {
"message": "Shaders"
},

View File

@@ -5,7 +5,17 @@ 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',
'minecraft_java_server',
]
export default defineNuxtRouteMiddleware(async (to) => {
// Only handle project routes
@@ -23,6 +33,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 +46,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('/')

View File

@@ -73,6 +73,7 @@
"
/>
</NewModal>
<OpenInAppModal ref="openInAppModal" />
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
@@ -432,7 +433,15 @@
}"
>
<div class="normal-page__header relative my-4">
<ProjectHeader :project="project" :member="!!currentMember">
<component
:is="isServerProject ? ServerProjectHeader : ProjectHeader"
v-if="projectV3Loaded"
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 +455,7 @@
<div class="hidden sm:contents">
<ButtonStyled
v-if="!isServerProject"
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
@@ -458,8 +468,6 @@
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
@mouseenter="loadVersions"
@focus="loadVersions"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
@@ -468,10 +476,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 +506,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 +772,7 @@
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectHeader>
</component>
<ProjectMemberHeader
v-if="currentMember"
:project="project"
@@ -796,7 +831,19 @@
</div>
<div class="normal-page__sidebar">
<ProjectSidebarServerInfo
v-if="isServerProject && serverDataLoaded"
: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="projectV3Loaded && !isServerProject"
:project="project"
:tags="tags"
:v3-metadata="projectV3"
@@ -805,9 +852,14 @@
<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"
/>
<ProjectSidebarTags
:project="project"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarCreators
:organization="organization"
:members="members"
@@ -827,7 +879,7 @@
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div class="details-list__item">
<div v-if="projectV3Loaded && !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,
@@ -1041,6 +1098,7 @@ const debug = useDebugLogger('DownloadModal')
const settingsModal = ref()
const downloadModal = ref()
const openInAppModal = ref()
const overTheTopDownloadAnimation = ref()
const userSelectedGameVersion = ref(null)
@@ -1051,6 +1109,9 @@ const gameVersionFilterInput = ref()
const versionFilter = ref('')
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
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()
@@ -1536,13 +1612,104 @@ const project = computed(() => {
const projectId = computed(() => projectRaw.value?.id)
// V3 Project
const { data: projectV3, error: _projectV3Error } = useQuery({
const {
data: projectV3,
error: _projectV3Error,
isPending: projectV3Pending,
} = useQuery({
queryKey: computed(() => ['project', 'v3', projectId.value]),
queryFn: () => client.labrinth.projects_v3.get(projectId.value),
staleTime: STALE_TIME,
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, isPending: serverModpackVersionPending } = 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, isPending: serverModpackProjectPending } = useQuery({
queryKey: computed(() => ['sidebar-modpack-project', serverModpackProjectId.value]),
queryFn: () => client.labrinth.projects_v3.get(serverModpackProjectId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!serverModpackProjectId.value),
})
const serverDataLoaded = computed(() => {
if (!projectV3.value) return false
if (serverModpackVersionId.value && serverModpackVersionPending.value) return false
if (serverModpackProjectId.value && serverModpackProjectPending.value) return false
return true
})
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:
serverModpackProject.value.id !== projectId.value
? () => navigateTo(`/modpack/${serverModpackProject.value.slug}`)
: undefined,
onclickVersion:
serverModpackProject.value.id !== projectId.value
? () =>
navigateTo(
`/modpack/${serverModpackProject.value.slug}/version/${serverModpackVersion.value?.id}`,
)
: undefined,
onclickDownload: primaryFile?.url
? () => navigateTo(primaryFile.url, { external: true })
: undefined,
showCustomModpackTooltip: serverModpackProject.value.id === projectId.value,
}
})
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']),
@@ -1664,8 +1831,10 @@ async function updateProjectRoute() {
async function invalidateProject() {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', routeProjectId.value] })
if (routeProjectId.value !== projectId.value) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId.value] })
}
// Prefix match — invalidates members, versions, dependencies, organization
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value] })
@@ -1711,7 +1880,6 @@ const patchProjectMutation = useMutation({
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async () => {
@@ -1764,6 +1932,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 +2009,6 @@ const patchIconMutation = useMutation({
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async () => {
@@ -2147,7 +2365,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 +2520,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 +2536,19 @@ const navLinks = computed(() => {
{
label: formatMessage(messages.changelogTab),
href: `${projectUrl}/changelog`,
shown: hasVersions.value,
shown:
hasVersions.value &&
projectV3Loaded.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) &&
projectV3Loaded.value &&
projectV3.value?.minecraft_server === undefined,
subpages: [`${projectUrl}/version/`],
onHover: loadVersions,
},
@@ -2335,6 +2583,7 @@ provideProjectPageContext({
// Mutation functions
patchProject,
patchProjectV3,
patchIcon,
setProcessing,

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()

View File

@@ -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"

View File

@@ -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) => {

View File

@@ -10,14 +10,70 @@
@proceed="deleteProject"
/>
<section class="universal-card">
<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>
<label for="project-icon">
<span class="label__title">Icon</span>
<div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<StyledInput
id="project-name"
v-model="name"
:maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<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"
@@ -50,47 +106,83 @@
</button>
</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"
<!-- 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>
<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"
<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"
wrapper-class="summary-input"
@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' &&
@@ -109,17 +201,10 @@
client-side functionality.
</span>
</label>
<Multiselect
id="project-env-client"
<Combobox
v-model="clientSide"
class="small-multiselect"
:options="sideTypeOptions"
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>
@@ -133,31 +218,28 @@
server.
</span>
</label>
<Multiselect
id="project-env-server"
<Combobox
v-model="serverSide"
class="small-multiselect"
:options="sideTypeOptions"
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">
<div>
<label>
<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">
</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>
</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,9 +503,13 @@ 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 (hasV2Changes) {
await patchProject(basePatchData.value)
}
if (deletedIcon.value) {
@@ -366,6 +519,18 @@ const saveChanges = async () => {
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
}
}
const showPreviewImage = (files) => {
@@ -378,6 +543,79 @@ 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 +647,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 +677,6 @@ svg {
}
}
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
.small-multiselect {
max-width: 15rem;
}
.button-group {
justify-content: flex-start;
}

View File

@@ -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>

View File

@@ -0,0 +1,496 @@
<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"
>Languages <span class="font-normal text-secondary">(optional)</span></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]">
<div class="flex items-center justify-between">
<label for="java-address">
<span class="label__title !m-0 !text-contrast">Java address</span>
</label>
</div>
<div
class="mt-2 flex items-center gap-2"
@focusout="
() => {
if (!lastPingAddressChanged && javaPingResult) return
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="javaAddress"
class="mt-2 flex gap-1.5"
:class="{
'items-center': javaPingResult?.online,
'items-start': javaPingResult && !javaPingResult.online,
}"
>
<ButtonStyled
v-if="(javaAddress && javaPingResult) || javaPingLoading"
circular
type="transparent"
size="small"
color="oranges"
>
<button
v-tooltip="'Refresh ping'"
:disabled="javaPingLoading"
@click="pingJavaServer"
>
<SpinnerIcon v-if="javaPingLoading" class="animate-spin" />
<RefreshCwIcon v-else />
</button>
</ButtonStyled>
<div
v-if="javaPingResult !== null && !javaPingLoading && javaPingResult.online"
class="mt-0.5 flex items-center gap-1.5 text-green"
>
Server is online!
<template v-if="javaPingResult.latency">
Latency: {{ javaPingResult.latency }}ms
</template>
</div>
<div v-else-if="javaPingResult !== null && !javaPingLoading" class="mt-0.5 text-orange">
We couldnt ping this server. It may be blocked by your host so try refreshing a few
times. If it still doesnt respond please
<a
class="inline underline"
href="https://support.modrinth.com"
target="_blank"
rel="noopener noreferrer"
>
contact support</a
>.
</div>
</div>
</div>
<!-- Bedrock Address -->
<div class="max-w-[600px]">
<label for="bedrock-address">
<span class="label__title !text-contrast"
>Bedrock address
<span class="font-normal text-secondary">(optional)</span>
</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 { RefreshCwIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
UnsavedChangesPopup,
} from '@modrinth/ui'
import { Multiselect } from 'vue-multiselect'
import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue'
const PING_TIMEOUT_MS = 5000
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
const javaAddress = ref('')
const javaPort = ref(25565)
const bedrockAddress = ref('')
const bedrockPort = ref(19132)
const country = ref('')
const languages = ref([])
const javaPingLoading = ref(false)
const javaPingResult = ref(null)
const lastPingedAddress = ref({ address: '', port: null })
const lastPingAddressChanged = computed(() => {
return (
javaAddress.value.trim() !== lastPingedAddress.value.address ||
javaPort.value !== lastPingedAddress.value.port
)
})
let pingDebounceTimer = null
watch([javaAddress, javaPort], () => {
clearTimeout(pingDebounceTimer)
pingDebounceTimer = setTimeout(() => {
pingJavaServer()
}, 500)
})
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
})
async function pingJavaServer() {
const address = javaAddress.value?.trim()
if (!address) {
javaPingResult.value = null
return
}
javaPingLoading.value = true
javaPingResult.value = null
const port = javaPort.value || 25565
try {
await Promise.race([
client.labrinth.server_ping_internal.pingMinecraftJava({
address,
port,
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Ping timed out')), PING_TIMEOUT_MS),
),
])
javaPingResult.value = { online: true, latency: null }
} catch {
javaPingResult.value = { online: false, latency: null }
} finally {
javaPingLoading.value = false
lastPingedAddress.value = { address, port }
}
}
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 ?? []
pingJavaServer()
}
// initialize projectV3 values once
if (projectV3.value) {
initFromProjectV3(projectV3.value)
} else {
const stop = watch(
() => projectV3.value,
(v3) => {
if (!v3) return
initFromProjectV3(v3)
stop()
},
)
}
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 addressChanged =
javaAddress.value.trim() !== (projectV3.value?.minecraft_java_server?.address ?? '') ||
javaPort.value !== (projectV3.value?.minecraft_java_server?.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() {
if (javaAddress.value.trim() && !javaPingResult.value?.online) {
addNotification({
title: 'Cannot save',
text: 'The Java server must be reachable before saving. Please ensure the ping succeeds.',
type: 'error',
})
return
}
saving.value = true
try {
const hasV3Changes = Object.keys(v3PatchData.value).length > 0
if (hasV3Changes) {
await patchProjectV3(v3PatchData.value)
}
} finally {
saving.value = false
}
}
</script>

View File

@@ -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

View File

@@ -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"

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 },
]"

View File

@@ -159,7 +159,7 @@
<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)">
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
@@ -454,6 +454,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' &&

View File

@@ -33,6 +33,7 @@ import {
StyledInput,
Toggle,
useSearch,
useServerSearch,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
@@ -84,6 +85,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] : []))
@@ -336,6 +341,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,15 +370,30 @@ 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}`
},
{
watch: false,
transform: (hits) => {
transform: (
hits: Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults,
): Labrinth.Search.v2.SearchResults => {
noLoad.value = false
return hits as Labrinth.Search.v2.SearchResults
if ('hits_per_page' in hits) {
return {
hits: hits.hits as unknown as Labrinth.Search.v2.ResultSearchProject[],
total_hits: hits.total_hits,
limit: hits.hits_per_page,
offset: (hits.page - 1) * hits.hits_per_page,
}
}
return hits
},
},
)
@@ -395,7 +437,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 +448,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 +492,32 @@ 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
}
</script>
<template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
@@ -547,6 +621,36 @@ useSeoMeta({
@update:model-value="updateSearchResults()"
/>
</div>
<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}`"
@@ -582,6 +686,7 @@ useSeoMeta({
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</template>
</div>
</aside>
<section class="normal-page__content">
@@ -601,10 +706,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 +755,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 +770,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,6 +787,32 @@ useSeoMeta({
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
>
<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"
@@ -689,8 +835,8 @@ useSeoMeta({
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side,
serverSide: result.server_side,
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
}
: undefined
"
@@ -751,6 +897,7 @@ useSeoMeta({
</template>
</template>
</ProjectCard>
</template>
</ProjectCardList>
</div>
<div class="pagination-after">

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')),
)
})

View File

@@ -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>

View File

@@ -159,7 +159,7 @@
<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)">
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>

View File

@@ -48,6 +48,7 @@ export default defineNuxtPlugin({
modpack: 'list',
shader: 'gallery',
datapack: 'list',
server: 'list',
user: 'list',
collection: 'list',
},

View File

@@ -0,0 +1,426 @@
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
const uploadedVersion: Labrinth.Versions.v3.Version = 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,
]

View File

@@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@@ -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',

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

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

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, components AS \"components: Json<exp::ProjectSerial>\"\n FROM mods\n WHERE\n status = ANY($1)\n AND 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": [
"TextArray"
]
},
"nullable": [
false,
false
]
},
"hash": "f6e0f4c2f9e28706b7fc2abe306179e5c600b1449444e96ef0000fc14334dd5b"
}

View File

@@ -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"
}

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",
@@ -43,6 +44,7 @@ deadpool-redis.workspace = true
derive_more = { workspace = true, features = ["deref", "deref_mut"] }
dotenvy = { workspace = true }
either = { workspace = true }
elytra-ping = { workspace = true }
eyre = { workspace = true }
futures = { workspace = true }
futures-util = { workspace = true }

View File

@@ -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';

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) => {
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;
}
Err(error) => {
error!(%error, "Failed to index email queue");
}
}
}
}
pub async fn update_bank_balances(pool: PgPool) {
Ok(())
}
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
@@ -144,13 +150,10 @@ pub async fn release_scheduled(pool: PgPool) {
crate::models::projects::ProjectStatus::Scheduled.as_str(),
)
.execute(&pool)
.await;
.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
@@ -159,77 +162,95 @@ 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);
}
.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:?}");
}
.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 {

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))
}

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")]

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,
}

View File

@@ -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(),
}
}
}

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;
}

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:#}");
}
}
});

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